Merge matrix-react-sdk into element-web

Merge remote-tracking branch 'repomerge/t3chguy/repomerge' into t3chguy/repo-merge

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-10-15 14:57:26 +01:00
commit f0ee7f7905
No known key found for this signature in database
GPG key ID: A2B008A5F49F5D0D
3265 changed files with 484599 additions and 699 deletions

6
playwright/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/test-results/
/html-report/
/logs/
# Only commit snapshots from Linux
/snapshots/**/*.png
!/snapshots/**/*-linux.png

12
playwright/@types/playwright-core.d.ts vendored Normal file
View file

@ -0,0 +1,12 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
declare module "playwright-core/lib/utils" {
// This type is not public in playwright-core utils
export function sanitizeForFilePath(filePath: string): string;
}

9
playwright/Dockerfile Normal file
View file

@ -0,0 +1,9 @@
FROM mcr.microsoft.com/playwright:v1.46.1-jammy
WORKDIR /work
# fonts-dejavu is needed for the same RTL rendering as on CI
RUN apt-get update && apt-get -y install docker.io fonts-dejavu
COPY docker-entrypoint.sh /opt/docker-entrypoint.sh
ENTRYPOINT ["bash", "/opt/docker-entrypoint.sh"]

View file

@ -0,0 +1,5 @@
#!/bin/bash
set -e
npx playwright test --update-snapshots --reporter line $@

View file

@ -0,0 +1,158 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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,34 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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 because Playwright doesn't
// recurse into iframes when looking for elements
const header = 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, i.e. no iframes.
const header = page.getByText("Element does not support this browser");
await expect(header).toBeVisible();
await expect(page).toMatchScreenshot("unsupported-browser-CompatibilityView.png");
});

View file

@ -0,0 +1,37 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/*
* Tests for application startup with guest registration enabled on the server.
*/
import { expect, test } from "../../element-web-test";
test.use({
startHomeserverOpts: "guest-enabled",
config: async ({ homeserver }, use) => {
await use({
default_server_config: {
"m.homeserver": { base_url: homeserver.config.baseUrl },
},
});
},
});
test("Shows the welcome page by default", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible();
await expect(page.getByRole("link", { name: "Sign in" })).toBeVisible();
});
test("Room link correctly loads a room view", async ({ page }) => {
await page.goto("/#/room/!room:id");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
await expect(page).toHaveURL(/\/#\/room\/!room:id$/);
await expect(page.getByRole("heading", { name: "Join the conversation with an account" })).toBeVisible();
});

View file

@ -0,0 +1,60 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { expect, test } from "../../element-web-test";
import { ElementAppPage } from "../../pages/ElementAppPage";
/*
* Tests for application startup with credentials stored in localstorage.
*/
test.use({ displayName: "Boris" });
test("Shows the homepage by default", async ({ pageWithCredentials: page }) => {
await page.goto("/");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
await expect(page).toHaveURL(/\/#\/home/);
await expect(page.getByRole("heading", { name: "Welcome Boris", exact: true })).toBeVisible();
});
test("Shows the last known page on reload", async ({ pageWithCredentials: page }) => {
await page.goto("/");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
const app = new ElementAppPage(page);
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
// Navigate away
await page.goto("about:blank");
// And back again
await page.goto("/");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
// Check that the room reloaded
await expect(page).toHaveURL(/\/#\/room\//);
await expect(page.locator(".mx_RoomHeader")).toContainText("Test Room");
});
test("Room link correctly loads a room view", async ({ pageWithCredentials: page }) => {
await page.goto("/#/room/!room:id");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
await expect(page).toHaveURL(/\/#\/room\/!room:id$/);
await expect(page.getByRole("button", { name: "Join the discussion" })).toBeVisible();
});
test("Login link redirects to home page", async ({ pageWithCredentials: page }) => {
await page.goto("/#/login");
await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 });
await expect(page).toHaveURL(/\/#\/home/);
await expect(page.getByRole("heading", { name: "Welcome Boris", exact: true })).toBeVisible();
});

View file

@ -0,0 +1,347 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Suguru Hirahara
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout";
import { ElementAppPage } from "../../pages/ElementAppPage";
test.describe("Audio player", () => {
test.use({
displayName: "Hanako",
});
const uploadFile = async (page: Page, file: string) => {
// Upload a file from the message composer
await page.locator(".mx_MessageComposer_actions input[type='file']").setInputFiles(file);
// Find and click primary "Upload" button
await page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click();
// Wait until the file is sent
await expect(page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible();
await expect(page.locator(".mx_EventTile.mx_EventTile_last .mx_EventTile_receiptSent")).toBeVisible();
// wait for the tile to finish loading
await expect(
page
.locator(".mx_AudioPlayer_mediaName")
.last()
.filter({ hasText: file.split("/").at(-1) }),
).toBeVisible();
};
/**
* Take snapshots of mx_EventTile_last on each layout, outputting log for reference/debugging.
* @param detail The snapshot name. Used for outputting logs too.
* @param monospace This changes the font used to render the UI from a default one to Inconsolata. Set to false by default.
*/
const takeSnapshots = async (page: Page, app: ElementAppPage, detail: string, monospace = false) => {
// Check that the audio player is rendered and its button becomes visible
const checkPlayerVisibility = async (locator: Locator) => {
// Assert that the audio player and media information are visible
const mediaInfo = locator.locator(
".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container .mx_AudioPlayer_mediaInfo",
);
await expect(mediaInfo.locator(".mx_AudioPlayer_mediaName", { hasText: ".ogg" })).toBeVisible(); // extension
await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "00:01" })).toBeVisible();
await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "(3.56 KB)" })).toBeVisible(); // actual size
// Assert that the play button can be found and is visible
await expect(locator.getByRole("button", { name: "Play" })).toBeVisible();
if (monospace) {
// Assert that the monospace timer is visible
await expect(locator.locator("[role='timer']")).toHaveCSS("font-family", "Inconsolata");
}
};
if (monospace) {
// Enable system font and monospace setting
await app.settings.setValue("useBundledEmojiFont", null, SettingLevel.DEVICE, false);
await app.settings.setValue("useSystemFont", null, SettingLevel.DEVICE, true);
await app.settings.setValue("systemFont", null, SettingLevel.DEVICE, "Inconsolata");
}
// Check the status of the seek bar
expect(await page.locator(".mx_AudioPlayer_seek input[type='range']").count()).toBeGreaterThan(0);
// Enable IRC layout
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
const ircTile = page.locator(".mx_EventTile_last[data-layout='irc']");
// Click the event timestamp to highlight EventTile in case it is not visible
await ircTile.locator(".mx_MessageTimestamp").click();
// Assert that rendering of the player settled and the play button is visible before taking a snapshot
await checkPlayerVisibility(ircTile);
const screenshotOptions = {
css: `
/* The timestamp is of inconsistent width depending on the time the test runs at */
.mx_MessageTimestamp {
display: none !important;
}
/* The MAB showing up on hover is not needed for the test */
.mx_MessageActionBar {
display: none !important;
}
`,
mask: [page.locator(".mx_AudioPlayer_seek")],
};
// Take a snapshot of mx_EventTile_last on IRC layout
await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot(
`${detail.replaceAll(" ", "-")}-irc-layout.png`,
screenshotOptions,
);
// Take a snapshot on modern/group layout
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
const groupTile = page.locator(".mx_EventTile_last[data-layout='group']");
await groupTile.locator(".mx_MessageTimestamp").click();
await checkPlayerVisibility(groupTile);
await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot(
`${detail.replaceAll(" ", "-")}-group-layout.png`,
screenshotOptions,
);
// Take a snapshot on bubble layout
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
const bubbleTile = page.locator(".mx_EventTile_last[data-layout='bubble']");
await bubbleTile.locator(".mx_MessageTimestamp").click();
await checkPlayerVisibility(bubbleTile);
await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot(
`${detail.replaceAll(" ", "-")}-bubble-layout.png`,
screenshotOptions,
);
};
test.beforeEach(async ({ page, app, user }) => {
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
// Wait until configuration is finished
await expect(
page
.locator(".mx_GenericEventListSummary[data-layout='group'] .mx_GenericEventListSummary_summary")
.getByText(`${user.displayName} created and configured the room.`),
).toBeVisible();
});
test("should be correctly rendered - light theme", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (light theme)");
});
test("should be correctly rendered - light theme with monospace font", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (light theme, monospace font)", true); // Enable monospace
});
test("should be correctly rendered - high contrast theme", async ({ page, app }) => {
// Disable system theme in case ThemeWatcher enables the theme automatically,
// so that the high contrast theme can be enabled
await app.settings.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
// Enable high contrast manually
const settings = await app.settings.openUserSettings("Appearance");
await settings.getByRole("radio", { name: "High contrast" }).click();
await app.closeDialog();
await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (high contrast)");
});
test("should be correctly rendered - dark theme", async ({ page, app }) => {
// Enable dark theme
await app.settings.setValue("theme", null, SettingLevel.ACCOUNT, "dark");
await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
await takeSnapshots(page, app, "Selected EventTile of audio player (dark theme)");
});
test("should play an audio file", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec.ogg");
// Assert that the audio player is rendered
const container = page.locator(".mx_EventTile_last .mx_AudioPlayer_container");
// Assert that the counter is zero before clicking the play button
await expect(container.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
// Find and click "Play" button, the wait is to make the test less flaky
await expect(container.getByRole("button", { name: "Play" })).toBeVisible();
await container.getByRole("button", { name: "Play" }).click();
// Assert that "Pause" button can be found
await expect(container.getByRole("button", { name: "Pause" })).toBeVisible();
// Assert that the timer is reset when the audio file finished playing
await expect(container.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
// Assert that "Play" button can be found
await expect(container.getByRole("button", { name: "Play" })).toBeVisible();
});
test("should support downloading an audio file", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec.ogg");
const downloadPromise = page.waitForEvent("download");
// Find and click "Download" button on MessageActionBar
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Download" }).click();
// Assert that the file was downloaded
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("1sec.ogg");
});
test("should support replying to audio file with another audio file", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec.ogg");
// Assert the audio player is rendered
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
// Find and click "Reply" button on MessageActionBar
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();
// Reply to the player with another audio file
await uploadFile(page, "playwright/sample-files/1sec.ogg");
// Assert that the audio player is rendered
await expect(tile.locator(".mx_AudioPlayer_container")).toBeVisible();
// Assert that replied audio file is rendered as file button inside ReplyChain
const button = tile.locator(".mx_ReplyChain_wrapper .mx_MFileBody_info[role='button']");
// Assert that the file button has file name
await expect(button.locator(".mx_MFileBody_info_filename")).toBeVisible();
await takeSnapshots(page, app, "Selected EventTile of audio player with a reply");
});
test("should support creating a reply chain with multiple audio files", async ({ page, app, user }) => {
// Note: "mx_ReplyChain" element is used not only for replies which
// create a reply chain, but also for a single reply without a replied
// message. This test checks whether a reply chain which consists of
// multiple audio file replies is rendered properly.
const tile = page.locator(".mx_EventTile_last");
// Find and click "Reply" button
const clickButtonReply = async () => {
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();
};
await uploadFile(page, "playwright/sample-files/upload-first.ogg");
// Assert that the audio player is rendered
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
await clickButtonReply();
// Reply to the player with another audio file
await uploadFile(page, "playwright/sample-files/upload-second.ogg");
// Assert that the audio player is rendered
await expect(page.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
await clickButtonReply();
// Reply to the player with yet another audio file to create a reply chain
await uploadFile(page, "playwright/sample-files/upload-third.ogg");
// Assert that the audio player is rendered
await expect(tile.locator(".mx_AudioPlayer_container")).toBeVisible();
// Assert that there are two "mx_ReplyChain" elements
await expect(tile.locator(".mx_ReplyChain")).toHaveCount(2);
// Assert that one line contains the user name
await expect(tile.locator(".mx_ReplyChain .mx_ReplyTile_sender").getByText(user.displayName)).toBeVisible();
// Assert that the other line contains the file button
await expect(tile.locator(".mx_ReplyChain .mx_MFileBody")).toBeVisible();
// Click "In reply to"
await tile.locator(".mx_ReplyChain .mx_ReplyChain_show", { hasText: "In reply to" }).click();
const replyChain = tile.locator(".mx_ReplyChain:first-of-type");
// Assert that "In reply to" has disappeared
await expect(replyChain.getByText("In reply to")).not.toBeVisible();
// Assert that the file button contains the name of the file sent at first
await expect(
replyChain
.locator(".mx_MFileBody_info[role='button']")
.locator(".mx_MFileBody_info_filename", { hasText: "upload-first.ogg" }),
).toBeVisible();
// Take snapshots
await takeSnapshots(page, app, "Selected EventTile of audio player with a reply chain");
});
test("should be rendered, play, and support replying on a thread", async ({ page, app }) => {
await uploadFile(page, "playwright/sample-files/1sec-long-name-audio-file.ogg");
// On the main timeline
const messageList = page.locator(".mx_RoomView_MessageList");
// Assert the audio player is rendered
await expect(messageList.locator(".mx_EventTile_last .mx_AudioPlayer_container")).toBeVisible();
// Find and click "Reply in thread" button
await messageList.locator(".mx_EventTile_last").hover();
await messageList.locator(".mx_EventTile_last").getByRole("button", { name: "Reply in thread" }).click();
// On a thread
const thread = page.locator(".mx_ThreadView");
const threadTile = thread.locator(".mx_EventTile_last");
const audioPlayer = threadTile.locator(".mx_AudioPlayer_container");
// Assert that the counter is zero before clicking the play button
await expect(audioPlayer.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
// Find and click "Play" button, the wait is to make the test less flaky
await expect(audioPlayer.getByRole("button", { name: "Play" })).toBeVisible();
await audioPlayer.getByRole("button", { name: "Play" }).click();
// Assert that "Pause" button can be found
await expect(audioPlayer.getByRole("button", { name: "Pause" })).toBeVisible();
// Assert that the timer is reset when the audio file finished playing
await expect(audioPlayer.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
// Assert that "Play" button can be found
await expect(audioPlayer.getByRole("button", { name: "Play" })).not.toBeDisabled();
// Find and click "Reply" button
await threadTile.hover();
await threadTile.getByRole("button", { name: "Reply", exact: true }).click();
const composer = thread.locator(".mx_MessageComposer--compact");
// Assert that the reply preview contains audio ReplyTile the file info button
await expect(
composer.locator(".mx_ReplyPreview .mx_ReplyTile_audio .mx_MFileBody_info[role='button']"),
).toBeVisible();
// Select :smile: emoji and send it
await composer.getByTestId("basicmessagecomposer").fill(":smile:");
await composer.locator(".mx_Autocomplete_Completion[aria-selected='true']").click();
await composer.getByTestId("basicmessagecomposer").press("Enter");
// Assert that the file name is rendered on the file button
await expect(threadTile.locator(".mx_ReplyTile_audio .mx_MFileBody_info[role='button']")).toBeVisible();
});
});

View file

@ -0,0 +1,128 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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 }) => {
// Set a fixed time rather than masking off the line with the time in it: we don't need to worry
// about the width changing and we can actually test this line looks correct.
page.clock.setSystemTime(new Date("2024-01-01T00:00:00Z"));
// 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: [
// 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,98 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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

@ -0,0 +1,350 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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("Rich text editor", () => {
test.use({
labsFlags: ["feature_wysiwyg_composer"],
});
test.describe("Commands", () => {
// TODO add tests for rich text mode
test.describe("Plain text mode", () => {
test("autocomplete behaviour tests", async ({ page }) => {
// Select plain text mode after composer is ready
await expect(page.locator("div[contenteditable=true]")).toBeVisible();
await page.getByRole("button", { name: "Hide formatting" }).click();
// Typing a single / displays the autocomplete menu and contents
await page.getByRole("textbox").press("/");
// Check that the autocomplete options are visible and there are more than 0 items
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeEmpty();
// Entering `//` or `/ ` hides the autocomplete contents
// Add an extra slash for `//`
await page.getByRole("textbox").press("/");
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Remove the extra slash to go back to `/`
await page.getByRole("textbox").press("Backspace");
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeEmpty();
// Add a trailing space for `/ `
await page.getByRole("textbox").press(" ");
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Typing a command that takes no arguments (/devtools) and selecting by click works
await page.getByRole("textbox").press("Backspace");
await page.getByRole("textbox").pressSequentially("dev");
await page.getByTestId("autocomplete-wrapper").getByText("/devtools").click();
// Check it has closed the autocomplete and put the text into the composer
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeVisible();
await expect(page.getByRole("textbox").getByText("/devtools")).toBeVisible();
// Send the message and check the devtools dialog appeared, then close it
await page.getByRole("button", { name: "Send message" }).click();
await expect(page.getByRole("dialog").getByText("Developer Tools")).toBeVisible();
await page.getByRole("button", { name: "Close dialog" }).click();
// Typing a command that takes arguments (/spoiler) and selecting with enter works
await page.getByRole("textbox").pressSequentially("/spoil");
await expect(page.getByTestId("autocomplete-wrapper").getByText("/spoiler")).toBeVisible();
await page.getByRole("textbox").press("Enter");
// Check it has closed the autocomplete and put the text into the composer
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeVisible();
await expect(page.getByRole("textbox").getByText("/spoiler")).toBeVisible();
// Enter some more text, then send the message
await page.getByRole("textbox").pressSequentially("this is the spoiler text ");
await page.getByRole("button", { name: "Send message" }).click();
// Check that a spoiler item has appeared in the timeline and locator the spoiler command text
await expect(page.locator("button.mx_EventTile_spoiler")).toBeVisible();
await expect(page.getByText("this is the spoiler text")).toBeVisible();
});
});
});
test.describe("Mentions", () => {
// TODO add tests for rich text mode
test.describe("Plain text mode", () => {
test.use({
botCreateOpts: {
displayName: "Bob",
},
});
// https://github.com/vector-im/element-web/issues/26037
test.skip("autocomplete behaviour tests", async ({ page, app, bot: bob }) => {
// Set up a private room so we have another user to mention
await app.client.createRoom({
is_direct: true,
invite: [bob.credentials.userId],
});
await app.viewRoomByName("Bob");
// Select plain text mode after composer is ready
await expect(page.locator("div[contenteditable=true]")).toBeVisible();
await page.getByRole("button", { name: "Hide formatting" }).click();
// Typing a single @ does not display the autocomplete menu and contents
await page.getByRole("textbox").press("@");
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Entering the first letter of the other user's name opens the autocomplete...
await page.getByRole("textbox").pressSequentially(bob.credentials.displayName.slice(0, 1));
// ...with the other user name visible, and clicking that username...
await page.getByTestId("autocomplete-wrapper").getByText(bob.credentials.displayName).click();
// ...inserts the username into the composer
const pill = page.getByRole("textbox").getByText(bob.credentials.displayName, { exact: false });
await expect(pill).toHaveAttribute("contenteditable", "false");
await expect(pill).toHaveAttribute("data-mention-type", "user");
// Send the message to clear the composer
await page.getByRole("button", { name: "Send message" }).click();
// Typing an @, then other user's name, then trailing space closes the autocomplete
await page.getByRole("textbox").pressSequentially(`@${bob.credentials.displayName} `);
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Send the message to clear the composer
await page.getByRole("button", { name: "Send message" }).click();
// Moving the cursor back to an "incomplete" mention opens the autocomplete
await page
.getByRole("textbox")
.pressSequentially(`initial text @${bob.credentials.displayName.slice(0, 1)} abc`);
await expect(page.getByTestId("autocomplete-wrapper")).toBeEmpty();
// Move the cursor left by 4 to put it to: `@B| abc`, check autocomplete displays
await page.getByRole("textbox").press("LeftArrow");
await page.getByRole("textbox").press("LeftArrow");
await page.getByRole("textbox").press("LeftArrow");
await page.getByRole("textbox").press("LeftArrow");
await expect(page.getByTestId("autocomplete-wrapper")).not.toBeEmpty();
// Selecting the autocomplete option using Enter inserts it into the composer
await page.getByRole("textbox").press("Enter");
const pill2 = page.getByRole("textbox").getByText(bob.credentials.displayName, { exact: false });
await expect(pill2).toHaveAttribute("contenteditable", "false");
await expect(pill2).toHaveAttribute("data-mention-type", "user");
});
});
});
test("sends a message when you click send or press Enter", async ({ page }) => {
// Type a message
await page.locator("div[contenteditable=true]").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").getByText("my message 0")).toBeVisible();
// Type another
await page.locator("div[contenteditable=true]").pressSequentially("my message 1");
// Send message
page.locator("div[contenteditable=true]").press("Enter");
// It was sent
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my message 1")).toBeVisible();
});
test("sends only one message when you press Enter multiple times", async ({ page }) => {
// Type a message
await page.locator("div[contenteditable=true]").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.locator("div[contenteditable=true]").press("Enter");
await page.locator("div[contenteditable=true]").press("Enter");
await page.locator("div[contenteditable=true]").press("Enter");
// It has been sent
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my message 0")).toBeVisible();
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body")).toHaveCount(1);
});
test("can write formatted text", async ({ page }) => {
await page.locator("div[contenteditable=true]").pressSequentially("my ");
await page.locator("div[contenteditable=true]").press(`${CtrlOrMeta}+KeyB`);
await page.locator("div[contenteditable=true]").pressSequentially("bold");
await page.locator("div[contenteditable=true]").press(`${CtrlOrMeta}+KeyB`);
await page.locator("div[contenteditable=true]").pressSequentially(" message");
await page.getByRole("button", { name: "Send message" }).click();
await expect(page.locator(".mx_EventTile_body strong").getByText("bold")).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 }) => {
// Type a message and press Enter
await page.locator("div[contenteditable=true]").pressSequentially("my message 3");
await page.locator("div[contenteditable=true]").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 page.locator("div[contenteditable=true]").press("Control+Enter");
// It was sent
await expect(
page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my message 3"),
).toBeVisible();
});
});
test.describe("links", () => {
test("create link with a forward selection", async ({ page }) => {
// Type a message
await page.locator("div[contenteditable=true]").pressSequentially("my message 0");
await page.locator("div[contenteditable=true]").press(`${CtrlOrMeta}+A`);
// Open link modal
await page.getByRole("button", { name: "Link" }).click();
// Fill the link field
await page.getByRole("textbox", { name: "Link" }).pressSequentially("https://matrix.org/");
// Click on save
await page.getByRole("button", { name: "Save" }).click();
// Send the message
await page.getByRole("button", { name: "Send message" }).click();
// It was sent
await expect(page.locator(".mx_EventTile_body a").getByText("my message 0")).toBeVisible();
await expect(page.locator(".mx_EventTile_body a")).toHaveAttribute(
"href",
new RegExp("https://matrix.org/"),
);
});
});
test.describe("Drafts", () => {
test("drafts with rich and plain text", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });
// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();
// Type some formatted text
await composer.pressSequentially("my ");
await composer.press(`${CtrlOrMeta}+KeyB`);
await composer.pressSequentially("bold");
// Change to plain text mode
await page.getByRole("button", { name: "Hide formatting" }).click();
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// assert the markdown
await expect(page.locator("div[contenteditable=true]", { hasText: "my __bold__" })).toBeVisible();
// Change to plain text mode and assert the markdown
await page.getByRole("button", { name: "Show formatting" }).click();
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// Send the message and assert the message
await page.getByRole("button", { name: "Send message" }).click();
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my bold")).toBeVisible();
});
test("draft with replies", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });
// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();
// Send a message
await composer.pressSequentially("my first message");
await page.getByRole("button", { name: "Send message" }).click();
// Click reply
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();
// Type reply text
await composer.pressSequentially("my reply");
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// Assert reply mode and reply text
await expect(page.getByText("Replying")).toBeVisible();
await expect(page.locator("div[contenteditable=true]", { hasText: "my reply" })).toBeVisible();
});
test("draft in threads", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });
// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();
// Send a message
await composer.pressSequentially("my first message");
await page.getByRole("button", { name: "Send message" }).click();
// Click reply
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Reply in thread" }).click();
const thread = page.locator(".mx_ThreadView");
const threadComposer = thread.locator("div[contenteditable=true]");
// Type threaded text
await threadComposer.pressSequentially("my threaded message");
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// Assert threaded draft
await expect(
thread.locator("div[contenteditable=true]", { hasText: "my threaded message" }),
).toBeVisible();
});
});
});
});

View file

@ -0,0 +1,34 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Create Room", () => {
test.use({ displayName: "Jim" });
test("should allow us to create a public room with name, topic & address set", async ({ page, user, app }) => {
const name = "Test room 1";
const topic = "This room is dedicated to this test and this test only!";
const dialog = await app.openCreateRoomDialog();
// Fill name & topic
await dialog.getByRole("textbox", { name: "Name" }).fill(name);
await dialog.getByRole("textbox", { name: "Topic" }).fill(topic);
// Change room to public
await dialog.getByRole("button", { name: "Room visibility" }).click();
await dialog.getByRole("option", { name: "Public room" }).click();
// Fill room address
await dialog.getByRole("textbox", { name: "Room address" }).fill("test-room-1");
// Submit
await dialog.getByRole("button", { name: "Create room" }).click();
await expect(page).toHaveURL(/\/#\/room\/#test-room-1:localhost/);
const header = page.locator(".mx_RoomHeader");
await expect(header).toContainText(name);
});
});

View file

@ -0,0 +1,112 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
async function expectBackupVersionToBe(page: Page, version: string) {
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
version + " (Algorithm: m.megolm_backup.v1.curve25519-aes-sha2)",
);
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(version);
}
test.describe("Backups", () => {
test.use({
displayName: "Hanako",
});
test("Create, delete and recreate a keys backup", async ({ page, user, app }, workerInfo) => {
// Create a backup
const securityTab = await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog");
// It's the first time and secure storage is not set up, so it will create one
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click();
// copy the recovery key to use it later
const securityKey = await app.getClipboard();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click();
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports
await page
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
.locator("..")
.click();
await expectBackupVersionToBe(page, "1");
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Delete it
await currentDialogLocator.getByTestId("dialog-primary-button").click(); // Click "Delete Backup"
// Create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible();
await currentDialogLocator.getByLabel("Security Key").fill(securityKey);
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
// Should be successful
await expect(currentDialogLocator.getByRole("heading", { name: "Success!" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "OK", exact: true }).click();
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports
await page
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
.locator("..")
.click();
await expectBackupVersionToBe(page, "2");
// ==
// Ensure that if you don't have the secret storage passphrase the backup won't be created
// ==
// First delete version 2
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
// Click "Delete Backup"
await currentDialogLocator.getByTestId("dialog-primary-button").click();
// Try to create another
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Security Key" })).toBeVisible();
// But cancel the security key dialog, to simulate not having the secret storage passphrase
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
await expect(currentDialogLocator.getByRole("heading", { name: "Starting backup…" })).toBeVisible();
// check that it failed
await expect(currentDialogLocator.getByText("Unable to create key backup")).toBeVisible();
// cancel
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
// go back to the settings to check that no backup was created (the setup button should still be there)
await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("button", { name: "Set up", exact: true })).toBeVisible();
});
});

View file

@ -0,0 +1,27 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { logIntoElement } from "./utils";
test.describe("Complete security", () => {
test.use({
displayName: "Jeff",
});
test("should go straight to the welcome screen if we have no signed device", async ({
page,
homeserver,
credentials,
}) => {
await logIntoElement(page, homeserver, credentials);
await expect(page.getByText("Welcome Jeff", { exact: true })).toBeVisible();
});
// see also "Verify device during login with SAS" in `verifiction.spec.ts`.
});

View file

@ -0,0 +1,248 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022-2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "../../element-web-test";
import { autoJoin, copyAndContinue, createSharedRoomWithUser, enableKeyBackup, verify } from "./utils";
import { Bot } from "../../pages/bot";
import { ElementAppPage } from "../../pages/ElementAppPage";
const checkDMRoom = async (page: Page) => {
const body = page.locator(".mx_RoomView_body");
await expect(body.getByText("Alice created this DM.")).toBeVisible();
await expect(body.getByText("Alice invited Bob")).toBeVisible({ timeout: 1000 });
await expect(body.locator(".mx_cryptoEvent").getByText("Encryption enabled")).toBeVisible();
};
const startDMWithBob = async (page: Page, bob: Bot) => {
await page.locator(".mx_RoomList").getByRole("button", { name: "Start chat" }).click();
await page.getByTestId("invite-dialog-input").fill(bob.credentials.userId);
await page.locator(".mx_InviteDialog_tile_nameStack_name").getByText("Bob").click();
await expect(
page.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText("Bob"),
).toBeVisible();
await page.getByRole("button", { name: "Go" }).click();
};
const testMessages = async (page: Page, bob: Bot, bobRoomId: string) => {
// check the invite message
await expect(
page.locator(".mx_EventTile", { hasText: "Hey!" }).locator(".mx_EventTile_e2eIcon_warning"),
).not.toBeVisible();
// Bob sends a response
await bob.sendMessage(bobRoomId, "Hoo!");
await expect(
page.locator(".mx_EventTile", { hasText: "Hoo!" }).locator(".mx_EventTile_e2eIcon_warning"),
).not.toBeVisible();
};
const bobJoin = async (page: Page, bob: Bot) => {
// Wait for Bob to get the invite
await bob.evaluate(async (cli) => {
const bobRooms = cli.getRooms();
if (!bobRooms.length) {
await new Promise<void>((resolve) => {
const onMembership = (_event) => {
cli.off(window.matrixcs.RoomMemberEvent.Membership, onMembership);
resolve();
};
cli.on(window.matrixcs.RoomMemberEvent.Membership, onMembership);
});
}
});
const roomId = await bob.joinRoomByName("Alice");
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Even though Alice has seen Bob's join event, Bob may not have done so yet. Wait for the sync to arrive.
await bob.awaitRoomMembership(roomId);
return roomId;
};
test.describe("Cryptography", function () {
test.use({
displayName: "Alice",
botCreateOpts: {
displayName: "Bob",
autoAcceptInvites: false,
},
});
for (const isDeviceVerified of [true, false]) {
test.describe(`setting up secure key backup should work isDeviceVerified=${isDeviceVerified}`, () => {
/**
* Verify that the `m.cross_signing.${keyType}` key is available on the account data on the server
* @param keyType
*/
async function verifyKey(app: ElementAppPage, keyType: string) {
const accountData: { encrypted: Record<string, Record<string, string>> } = await app.client.evaluate(
(cli, keyType) => cli.getAccountDataFromServer(`m.cross_signing.${keyType}`),
keyType,
);
expect(accountData.encrypted).toBeDefined();
const keys = Object.keys(accountData.encrypted);
const key = accountData.encrypted[keys[0]];
expect(key.ciphertext).toBeDefined();
expect(key.iv).toBeDefined();
expect(key.mac).toBeDefined();
}
test("by recovery code", async ({ page, app, user: aliceCredentials }) => {
// Verified the device
if (isDeviceVerified) {
await app.client.bootstrapCrossSigning(aliceCredentials);
}
await page.route("**/_matrix/client/v3/keys/signatures/upload", async (route) => {
// We delay this API otherwise the `Setting up keys` may happen too quickly and cause flakiness
await new Promise((resolve) => setTimeout(resolve, 500));
await route.continue();
});
await app.settings.openUserSettings("Security & Privacy");
await page.getByRole("button", { name: "Set up Secure Backup" }).click();
const dialog = page.locator(".mx_Dialog");
// Recovery key is selected by default
await dialog.getByRole("button", { name: "Continue" }).click();
await copyAndContinue(page);
// When the device is verified, the `Setting up keys` step is skipped
if (!isDeviceVerified) {
const uiaDialogTitle = page.locator(".mx_InteractiveAuthDialog .mx_Dialog_title");
await expect(uiaDialogTitle.getByText("Setting up keys")).toBeVisible();
await expect(uiaDialogTitle.getByText("Setting up keys")).not.toBeVisible();
}
await expect(dialog.getByText("Secure Backup successful")).toBeVisible();
await dialog.getByRole("button", { name: "Done" }).click();
await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible();
// Verify that the SSSS keys are in the account data stored in the server
await verifyKey(app, "master");
await verifyKey(app, "self_signing");
await verifyKey(app, "user_signing");
});
test("by passphrase", async ({ page, app, user: aliceCredentials }) => {
// Verified the device
if (isDeviceVerified) {
await app.client.bootstrapCrossSigning(aliceCredentials);
}
await app.settings.openUserSettings("Security & Privacy");
await page.getByRole("button", { name: "Set up Secure Backup" }).click();
const dialog = page.locator(".mx_Dialog");
// Select passphrase option
await dialog.getByText("Enter a Security Phrase").click();
await dialog.getByRole("button", { name: "Continue" }).click();
// Fill passphrase input
await dialog.locator("input").fill("new passphrase for setting up a secure key backup");
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
// Confirm passphrase
await dialog.locator("input").fill("new passphrase for setting up a secure key backup");
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
await copyAndContinue(page);
await expect(dialog.getByText("Secure Backup successful")).toBeVisible();
await dialog.getByRole("button", { name: "Done" }).click();
await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible();
// Verify that the SSSS keys are in the account data stored in the server
await verifyKey(app, "master");
await verifyKey(app, "self_signing");
await verifyKey(app, "user_signing");
});
});
}
test("Can reset cross-signing keys", async ({ page, app, user: aliceCredentials }) => {
const secretStorageKey = await enableKeyBackup(app);
// Fetch the current cross-signing keys
async function fetchMasterKey() {
return await test.step("Fetch master key from server", async () => {
const k = await app.client.evaluate(async (cli) => {
const userId = cli.getUserId();
const keys = await cli.downloadKeysForUsers([userId]);
return Object.values(keys.master_keys[userId].keys)[0];
});
console.log(`fetchMasterKey: ${k}`);
return k;
});
}
const masterKey1 = await fetchMasterKey();
// Find the "reset cross signing" button, and click it
await app.settings.openUserSettings("Security & Privacy");
await page.locator("div.mx_CrossSigningPanel_buttonRow").getByRole("button", { name: "Reset" }).click();
// Confirm
await page.getByRole("button", { name: "Clear cross-signing keys" }).click();
// Enter the 4S key
await page.getByPlaceholder("Security Key").fill(secretStorageKey);
await page.getByRole("button", { name: "Continue" }).click();
// Enter the password
await page.getByPlaceholder("Password").fill(aliceCredentials.password);
await page.getByRole("button", { name: "Continue" }).click();
await expect(async () => {
const masterKey2 = await fetchMasterKey();
expect(masterKey1).not.toEqual(masterKey2);
}).toPass();
// The dialog should have gone away
await expect(page.locator(".mx_Dialog")).toHaveCount(1);
});
test("creating a DM should work, being e2e-encrypted / user verification", async ({
page,
app,
bot: bob,
user: aliceCredentials,
}) => {
await app.client.bootstrapCrossSigning(aliceCredentials);
await startDMWithBob(page, bob);
// send first message
await page.getByRole("textbox", { name: "Send a message…" }).fill("Hey!");
await page.getByRole("textbox", { name: "Send a message…" }).press("Enter");
await checkDMRoom(page);
const bobRoomId = await bobJoin(page, bob);
await testMessages(page, bob, bobRoomId);
await verify(app, bob);
// Assert that verified icon is rendered
await page.getByTestId("base-card-back-button").click();
await page.getByLabel("Room info").nth(1).click();
await expect(page.locator('.mx_RoomSummaryCard_badges [data-kind="green"]')).toContainText("Encrypted");
// Take a snapshot of RoomSummaryCard with a verified E2EE icon
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("RoomSummaryCard-with-verified-e2ee.png");
});
test("should allow verification when there is no existing DM", async ({
page,
app,
bot: bob,
user: aliceCredentials,
}) => {
await app.client.bootstrapCrossSigning(aliceCredentials);
await autoJoin(bob);
// 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(app, bob);
});
});

View file

@ -0,0 +1,294 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022-2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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

@ -0,0 +1,106 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Locator, type Page } from "@playwright/test";
import { test as base, expect } from "../../element-web-test";
import { viewRoomSummaryByName } from "../right-panel/utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
const test = base.extend({
// eslint-disable-next-line no-empty-pattern
startHomeserverOpts: async ({}, use) => {
await use("dehydration");
},
config: async ({ homeserver, context }, use) => {
const wellKnown = {
"m.homeserver": {
base_url: homeserver.config.baseUrl,
},
"org.matrix.msc3814": true,
};
await context.route("https://localhost/.well-known/matrix/client", async (route) => {
await route.fulfill({ json: wellKnown });
});
await use({
default_server_config: wellKnown,
});
},
});
const ROOM_NAME = "Test room";
const NAME = "Alice";
function getMemberTileByName(page: Page, name: string): Locator {
return page.locator(`.mx_EntityTile, [title="${name}"]`);
}
test.describe("Dehydration", () => {
test.skip(isDendrite, "does not yet support dehydration v2");
test.use({
displayName: NAME,
});
test("Create dehydrated device", async ({ page, user, app }, workerInfo) => {
test.skip(workerInfo.project.name === "Legacy Crypto", "This test only works with Rust crypto.");
// Create a backup (which will create SSSS, and dehydrated device)
const securityTab = await app.settings.openUserSettings("Security & Privacy");
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
await expect(securityTab.getByText("Offline device enabled")).not.toBeVisible();
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog");
// It's the first time and secure storage is not set up, so it will create one
await expect(currentDialogLocator.getByRole("heading", { name: "Set up Secure Backup" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Save your Security Key" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Copy", exact: true }).click();
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
await expect(currentDialogLocator.getByRole("heading", { name: "Secure Backup successful" })).toBeVisible();
await currentDialogLocator.getByRole("button", { name: "Done", exact: true }).click();
// Open the settings again
await app.settings.openUserSettings("Security & Privacy");
// The Security tab should indicate that there is a dehydrated device present
await expect(securityTab.getByText("Offline device enabled")).toBeVisible();
await app.settings.closeDialog();
// the dehydrated device gets created with the name "Dehydrated
// device". We want to make sure that it is not visible as a normal
// device.
const sessionsTab = await app.settings.openUserSettings("Sessions");
await expect(sessionsTab.getByText("Dehydrated device")).not.toBeVisible();
await app.settings.closeDialog();
// now check that the user info right-panel shows the dehydrated device
// as a feature rather than as a normal device
await app.client.createRoom({ name: ROOM_NAME });
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
await getMemberTileByName(page, NAME).click();
await page.locator(".mx_UserInfo_devices .mx_UserInfo_expand").click();
await expect(page.locator(".mx_UserInfo_devices").getByText("Offline device enabled")).toBeVisible();
await expect(page.locator(".mx_UserInfo_devices").getByText("Dehydrated device")).not.toBeVisible();
});
});

View file

@ -0,0 +1,259 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import jsQR from "jsqr";
import type { JSHandle, Locator, Page } from "@playwright/test";
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 { Bot } from "../../pages/bot";
test.describe("Device verification", () => {
let aliceBotClient: Bot;
/** The backup version that was set up by the bot client. */
let expectedBackupVersion: string;
test.beforeEach(async ({ page, homeserver, credentials }) => {
// Visit the login page of the app, to load the matrix sdk
await page.goto("/#/login");
// wait for the page to load
await page.waitForSelector(".mx_AuthPage", { timeout: 30000 });
// Create a new device for alice
aliceBotClient = new Bot(page, homeserver, {
bootstrapCrossSigning: true,
bootstrapSecretStorage: true,
});
aliceBotClient.setCredentials(credentials);
// Backup is prepared in the background. Poll until it is ready.
const botClientHandle = await aliceBotClient.prepareClient();
await expect
.poll(async () => {
expectedBackupVersion = await botClientHandle.evaluate((cli) =>
cli.getCrypto()!.getActiveSessionBackupVersion(),
);
return expectedBackupVersion;
})
.not.toBe(null);
});
// Click the "Verify with another device" button, and have the bot client auto-accept it.
async function initiateAliceVerificationRequest(page: Page): Promise<JSHandle<VerificationRequest>> {
// alice bot waits for verification request
const promiseVerificationRequest = waitForVerificationRequest(aliceBotClient);
// Click on "Verify with another device"
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with another device" }).click();
// alice bot responds yes to verification request from alice
return promiseVerificationRequest;
}
test("Verify device with SAS during login", async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, homeserver, credentials);
// Launch the verification request between alice and the bot
const verificationRequest = await initiateAliceVerificationRequest(page);
// Handle emoji SAS verification
const infoDialog = page.locator(".mx_InfoDialog");
// the bot chooses to do an emoji verification
const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1"));
// Handle emoji request and check that emojis are matching
await doTwoWaySasVerification(page, verifier);
await infoDialog.getByRole("button", { name: "They match" }).click();
await infoDialog.getByRole("button", { name: "Got it" }).click();
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup
// For now we don't check that the backup key is in cache because it's a bit flaky,
// as we need to wait for the secret gossiping to happen and the settings dialog doesn't refresh automatically.
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, false);
});
test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => {
// A mode 0x02 verification: "self-verifying in which the current device does not yet trust the master key"
await logIntoElement(page, homeserver, credentials);
// Launch the verification request between alice and the bot
const verificationRequest = await initiateAliceVerificationRequest(page);
const infoDialog = page.locator(".mx_InfoDialog");
// feed the QR code into the verification request.
const qrData = await readQrCode(infoDialog);
const verifier = await verificationRequest.evaluateHandle(
(request, qrData) => request.scanQRCode(new Uint8Array(qrData)),
[...qrData],
);
// Confirm that the bot user scanned successfully
await expect(infoDialog.getByText("Almost there! Is your other device showing the same shield?")).toBeVisible();
await infoDialog.getByRole("button", { name: "Yes" }).click();
await infoDialog.getByRole("button", { name: "Got it" }).click();
// wait for the bot to see we have finished
await verifier.evaluate((verifier) => verifier.verify());
// the bot uploads the signatures asynchronously, so wait for that to happen
await page.waitForTimeout(1000);
// our device should trust the bot device
await app.client.evaluate(async (cli, aliceBotCredentials) => {
const deviceStatus = await cli
.getCrypto()!
.getDeviceVerificationStatus(aliceBotCredentials.userId, aliceBotCredentials.deviceId);
if (!deviceStatus.isVerified()) {
throw new Error("Bot device was not verified after QR code verification");
}
}, aliceBotClient.credentials);
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup
// For now we don't check that the backup key is in cache because it's a bit flaky,
// as we need to wait for the secret gossiping to happen and the settings dialog doesn't refresh automatically.
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, false);
});
test("Verify device with Security Phrase during login", async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, homeserver, credentials);
// Select the security phrase
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
// Fill the passphrase
const dialog = page.locator(".mx_Dialog");
await dialog.locator("input").fill("new passphrase");
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click();
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
});
test("Verify device with Security Key during login", async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, homeserver, credentials);
// Select the security phrase
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key or Phrase" }).click();
// Fill the security key
const dialog = page.locator(".mx_Dialog");
await dialog.getByRole("button", { name: "use your Security Key" }).click();
const aliceRecoveryKey = await aliceBotClient.getRecoveryKey();
await dialog.locator("#mx_securityKey").fill(aliceRecoveryKey.encodedPrivateKey);
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click();
// Check that our device is now cross-signed
await checkDeviceIsCrossSigned(app);
// Check that the current device is connected to key backup
// The backup decryption key should be in cache also, as we got it directly from the 4S
await checkDeviceIsConnectedKeyBackup(page, expectedBackupVersion, true);
});
test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts }) => {
await logIntoElement(page, homeserver, credentials);
/* Dismiss "Verify this device" */
const authPage = page.locator(".mx_AuthPage");
await authPage.getByRole("button", { name: "Skip verification for now" }).click();
await authPage.getByRole("button", { name: "I'll verify later" }).click();
await page.waitForSelector(".mx_MatrixChat");
const elementDeviceId = await page.evaluate(() => window.mxMatrixClientPeg.get().getDeviceId());
/* Now initiate a verification request from the *bot* device. */
const botVerificationRequest = await aliceBotClient.evaluateHandle(
async (client, { userId, deviceId }) => {
return client.getCrypto()!.requestDeviceVerification(userId, deviceId);
},
{ userId: credentials.userId, deviceId: elementDeviceId },
);
/* Check the toast for the incoming request */
const toast = await toasts.getToast("Verification requested");
// it should contain the device ID of the requesting device
await expect(toast.getByText(`${aliceBotClient.credentials.deviceId} from `)).toBeVisible();
// Accept
await toast.getByRole("button", { name: "Verify Session" }).click();
/* Click 'Start' to start SAS verification */
await page.getByRole("button", { name: "Start" }).click();
/* on the bot side, wait for the verifier to exist ... */
const verifier = await awaitVerifier(botVerificationRequest);
// ... confirm ...
botVerificationRequest.evaluate((verificationRequest) => verificationRequest.verifier.verify());
// ... and then check the emoji match
await doTwoWaySasVerification(page, verifier);
/* And we're all done! */
const infoDialog = page.locator(".mx_InfoDialog");
await infoDialog.getByRole("button", { name: "They match" }).click();
await expect(
infoDialog.getByText(`You've successfully verified (${aliceBotClient.credentials.deviceId})!`),
).toBeVisible();
await infoDialog.getByRole("button", { name: "Got it" }).click();
});
});
/** Extract the qrcode out of an on-screen html element */
async function readQrCode(base: Locator) {
const qrCode = base.locator('[alt="QR Code"]');
const imageData = await qrCode.evaluate<
{
colorSpace: PredefinedColorSpace;
width: number;
height: number;
buffer: number[];
},
HTMLImageElement
>(async (img) => {
// draw the image on a canvas
const myCanvas = new OffscreenCanvas(img.width, img.height);
const ctx = myCanvas.getContext("2d");
ctx.drawImage(img, 0, 0);
// read the image data
const imageData = ctx.getImageData(0, 0, myCanvas.width, myCanvas.height);
return {
colorSpace: imageData.colorSpace,
width: imageData.width,
height: imageData.height,
buffer: [...new Uint8ClampedArray(imageData.data.buffer)],
};
});
// now we can decode the QR code.
const result = jsQR(new Uint8ClampedArray(imageData.buffer), imageData.width, imageData.height);
return new Uint8Array(result.binaryData);
}

View file

@ -0,0 +1,311 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022-2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { expect, test } from "../../element-web-test";
import {
autoJoin,
createSecondBotDevice,
createSharedRoomWithUser,
enableKeyBackup,
logIntoElement,
logOutOfElement,
verify,
} from "./utils";
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, and wait for Bob to join it.
testRoomId = await createSharedRoomWithUser(app, bob.credentials.userId, {
name: "TestRoom",
initial_state: [
{
type: "m.room.encryption",
state_key: "",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
},
],
});
// Even though Alice has seen Bob's join event, Bob may not have done so yet. Wait for the sync to arrive.
await bob.awaitRoomMembership(testRoomId);
});
test("should show the correct shield on e2e events", async ({
page,
app,
bot: bob,
homeserver,
}, workerInfo) => {
// Bob has a second, not cross-signed, device
const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob);
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(await app.getTooltipForElement(lastE2eIcon)).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(await app.getTooltipForElement(lastE2eIcon)).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(await app.getTooltipForElement(lastTileE2eIcon)).toContainText(
"Encrypted by a device not verified by its owner.",
);
/* In legacy crypto: should show a grey padlock for a message from a deleted device.
* In rust crypto: should show a red padlock for a message from an unverified device.
* Rust crypto remembers the verification state of the sending device, so it will know that the device was
* unverified, even if it gets deleted. */
// 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(await app.getTooltipForElement(lastE2eIcon)).toContainText(
workerInfo.project.name === "Legacy Crypto"
? "Encrypted by an unknown or deleted device."
: "Encrypted by a device not verified by its owner.",
);
});
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(await app.getTooltipForElement(lastTileE2eIcon)).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 = await createSecondBotDevice(page, homeserver, bob);
// 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();
});
test("should show correct shields on events sent by devices which have since been deleted", async ({
page,
app,
bot: bob,
homeserver,
}) => {
// Our app is blocked from syncing while Bob sends his messages.
await app.client.network.goOffline();
// Bob sends a message from his verified device
await bob.sendMessage(testRoomId, "test encrypted from verified");
// And one from a second, not cross-signed, device
const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob);
await bobSecondDevice.waitForNextSync(); // make sure the client knows the room is encrypted
await bobSecondDevice.sendMessage(testRoomId, "test encrypted from unverified");
// ... and then logs out both devices.
await bob.evaluate((cli) => cli.logout(true));
await bobSecondDevice.evaluate((cli) => cli.logout(true));
// Let our app start syncing again
await app.client.network.goOnline();
// Wait for the messages to arrive. It can take quite a while for the sync to wake up.
const last = page.locator(".mx_EventTile_last");
await expect(last).toContainText("test encrypted from unverified", { timeout: 20000 });
const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon");
await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/);
await lastE2eIcon.focus();
await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText(
"Encrypted by a device not verified by its owner.",
);
const penultimate = page.locator(".mx_EventTile").filter({ hasText: "test encrypted from verified" });
await expect(penultimate.locator(".mx_EventTile_e2eIcon")).not.toBeVisible();
});
});
});

View file

@ -0,0 +1,56 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { expect, test } from "../../element-web-test";
import { autoJoin, createSecondBotDevice, createSharedRoomWithUser, verify } from "./utils";
import { bootstrapCrossSigningForClient } from "../../pages/client.ts";
/** Tests for the "invisible crypto" behaviour -- i.e., when the "exclude insecure devices" setting is enabled */
test.describe("Invisible cryptography", () => {
test.use({
displayName: "Alice",
botCreateOpts: { displayName: "Bob" },
labsFlags: ["feature_exclude_insecure_devices"],
});
test("Messages fail to decrypt when sender is previously verified", async ({
page,
bot: bob,
user: aliceCredentials,
app,
homeserver,
}) => {
await app.client.bootstrapCrossSigning(aliceCredentials);
await autoJoin(bob);
// create an encrypted room
const testRoomId = await createSharedRoomWithUser(app, bob.credentials.userId, {
name: "TestRoom",
initial_state: [
{
type: "m.room.encryption",
state_key: "",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
},
],
});
// Verify Bob
await verify(app, bob);
// Bob logs in a new device and resets cross-signing
const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob);
await bootstrapCrossSigningForClient(await bobSecondDevice.prepareClient(), bob.credentials, true);
/* should show an error for a message from a previously verified device */
await bobSecondDevice.sendMessage(testRoomId, "test encrypted from user that was previously verified");
const lastTile = page.locator(".mx_EventTile_last");
await expect(lastTile).toContainText("Verified identity has changed");
});
});

View file

@ -0,0 +1,60 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { createRoom, enableKeyBackup, logIntoElement, sendMessageInCurrentRoom } from "./utils";
test.describe("Logout tests", () => {
test.beforeEach(async ({ page, homeserver, credentials }) => {
await logIntoElement(page, homeserver, credentials);
});
test("Ask to set up recovery on logout if not setup", async ({ page, app }) => {
await createRoom(page, "E2e room", true);
// send a message (will be the first one so will create a new megolm session)
await sendMessageInCurrentRoom(page, "Hello secret world");
const locator = await app.settings.openUserMenu();
await locator.getByRole("menuitem", { name: "Sign out", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog");
await expect(
currentDialogLocator.getByRole("heading", { name: "You'll lose access to your encrypted messages" }),
).toBeVisible();
});
test("If backup is set up show standard confirm", async ({ page, app }) => {
await enableKeyBackup(app);
await createRoom(page, "E2e room", true);
// send a message (will be the first one so will create a new megolm session)
await sendMessageInCurrentRoom(page, "Hello secret world");
const locator = await app.settings.openUserMenu();
await locator.getByRole("menuitem", { name: "Sign out", exact: true }).click();
const currentDialogLocator = page.locator(".mx_Dialog");
await expect(currentDialogLocator.getByText("Are you sure you want to sign out?")).toBeVisible();
});
test("Logout directly if the user has no room keys", async ({ page, app }) => {
await createRoom(page, "Clear room", false);
await sendMessageInCurrentRoom(page, "Hello public world!");
const locator = await app.settings.openUserMenu();
await locator.getByRole("menuitem", { name: "Sign out", exact: true }).click();
// Should have logged out directly
await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible();
});
});

View file

@ -0,0 +1,64 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import path from "path";
import { readFile } from "node:fs/promises";
import { expect, test as base } from "../../element-web-test";
const test = base.extend({
// Replace the `user` fixture with one which populates the indexeddb data before starting the app.
user: async ({ context, pageWithCredentials: page, credentials }, use) => {
await page.route(`/test_indexeddb_cryptostore_dump/*`, async (route, request) => {
const resourcePath = path.join(__dirname, new URL(request.url()).pathname);
const body = await readFile(resourcePath, { encoding: "utf-8" });
await route.fulfill({ body });
});
await page.goto("/test_indexeddb_cryptostore_dump/index.html");
await use(credentials);
},
});
test.describe("migration", function () {
test.use({ displayName: "Alice" });
test("Should support migration from legacy crypto", async ({ context, user, page }, workerInfo) => {
test.skip(workerInfo.project.name === "Legacy Crypto", "This test only works with Rust crypto.");
test.slow();
// We should see a migration progress bar
await page.getByText("Hang tight.").waitFor({ timeout: 60000 });
// When the progress bar first loads, it should have a high max (one per megolm session to import), and
// a relatively low value.
const progressBar = page.getByRole("progressbar");
const initialProgress = parseFloat(await progressBar.getAttribute("value"));
const initialMax = parseFloat(await progressBar.getAttribute("max"));
expect(initialMax).toBeGreaterThan(4000);
expect(initialProgress).toBeGreaterThanOrEqual(0);
expect(initialProgress).toBeLessThanOrEqual(500);
// Later, the progress should pass 50%
await expect
.poll(
async () => {
const progressBar = page.getByRole("progressbar");
return (
(parseFloat(await progressBar.getAttribute("value")) * 100.0) /
parseFloat(await progressBar.getAttribute("max"))
);
},
{ timeout: 60000 },
)
.toBeGreaterThan(50);
// Eventually, we should get a normal matrix chat
await page.waitForSelector(".mx_MatrixChat", { timeout: 120000 });
});
});

View file

@ -0,0 +1,60 @@
# Dump of libolm indexeddb cryptostore
This directory contains, in `dump.json`, a dump of a real indexeddb store from a session using
libolm crypto.
The corresponding pickle key is `+1k2Ppd7HIisUY824v7JtV3/oEE4yX0TqtmNPyhaD7o`.
This directory also contains, in `index.html` and `load.js`, a page which will populate indexeddb with the data
(and the pickle key). This can be served via a Playwright [Route](https://playwright.dev/docs/api/class-route) so as to
populate the indexeddb before the main application loads. Note that encrypting the pickle key requires the test User ID
and Device ID, so they must be stored in `localstorage` before loading `index.html`.
## Creation of the dump file
The dump was created by pasting the following into the browser console:
```javascript
async function exportIndexedDb(name) {
const db = await new Promise((resolve, reject) => {
const dbReq = indexedDB.open(name);
dbReq.onerror = reject;
dbReq.onsuccess = () => resolve(dbReq.result);
});
const storeNames = db.objectStoreNames;
const exports = {};
for (const store of storeNames) {
exports[store] = [];
const txn = db.transaction(store, "readonly");
const objectStore = txn.objectStore(store);
await new Promise((resolve, reject) => {
const cursorReq = objectStore.openCursor();
cursorReq.onerror = reject;
cursorReq.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const entry = { value: cursor.value };
if (!objectStore.keyPath) {
entry.key = cursor.key;
}
exports[store].push(entry);
cursor.continue();
} else {
resolve();
}
};
});
}
return exports;
}
window.saveAs(
new Blob([JSON.stringify(await exportIndexedDb("matrix-js-sdk:crypto"), null, 2)], {
type: "application/json;charset=utf-8",
}),
"dump.json",
);
```
The pickle key is extracted via `mxMatrixClientPeg.get().crypto.olmDevice.pickleKey`.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,6 @@
<html>
<head>
<script src="load.js"></script>
</head>
Loading test data...
</html>

View file

@ -0,0 +1,220 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* Browser-side javascript to fetch the indexeddb dump file, and populate indexeddb. */
/** The pickle key corresponding to the data dump. */
const PICKLE_KEY = "+1k2Ppd7HIisUY824v7JtV3/oEE4yX0TqtmNPyhaD7o";
/**
* Populate an IndexedDB store with the test data from this directory.
*
* @param {any} data - IndexedDB dump to import
* @param {string} name - Name of the IndexedDB database to create.
*/
async function populateStore(data, name) {
const req = indexedDB.open(name, 11);
const db = await new Promise((resolve, reject) => {
req.onupgradeneeded = (ev) => {
const db = req.result;
const oldVersion = ev.oldVersion;
upgradeDatabase(oldVersion, db);
};
req.onerror = (ev) => {
reject(req.error);
};
req.onsuccess = () => {
const db = req.result;
resolve(db);
};
});
await importData(data, db);
return db;
}
/**
* Create the schema for the indexed db store
*
* @param {number} oldVersion - The current version of the store.
* @param {IDBDatabase} db - The indexeddb database.
*/
function upgradeDatabase(oldVersion, db) {
if (oldVersion < 1) {
const outgoingRoomKeyRequestsStore = db.createObjectStore("outgoingRoomKeyRequests", { keyPath: "requestId" });
outgoingRoomKeyRequestsStore.createIndex("session", ["requestBody.room_id", "requestBody.session_id"]);
outgoingRoomKeyRequestsStore.createIndex("state", "state");
}
if (oldVersion < 2) {
db.createObjectStore("account");
}
if (oldVersion < 3) {
const sessionsStore = db.createObjectStore("sessions", { keyPath: ["deviceKey", "sessionId"] });
sessionsStore.createIndex("deviceKey", "deviceKey");
}
if (oldVersion < 4) {
db.createObjectStore("inbound_group_sessions", { keyPath: ["senderCurve25519Key", "sessionId"] });
}
if (oldVersion < 5) {
db.createObjectStore("device_data");
}
if (oldVersion < 6) {
db.createObjectStore("rooms");
}
if (oldVersion < 7) {
db.createObjectStore("sessions_needing_backup", { keyPath: ["senderCurve25519Key", "sessionId"] });
}
if (oldVersion < 8) {
db.createObjectStore("inbound_group_sessions_withheld", { keyPath: ["senderCurve25519Key", "sessionId"] });
}
if (oldVersion < 9) {
const problemsStore = db.createObjectStore("session_problems", { keyPath: ["deviceKey", "time"] });
problemsStore.createIndex("deviceKey", "deviceKey");
db.createObjectStore("notified_error_devices", { keyPath: ["userId", "deviceId"] });
}
if (oldVersion < 10) {
db.createObjectStore("shared_history_inbound_group_sessions", { keyPath: ["roomId"] });
}
if (oldVersion < 11) {
db.createObjectStore("parked_shared_history", { keyPath: ["roomId"] });
}
}
/** Do the import of data into the database
*
* @param {any} json - The data to import.
* @param {IDBDatabase} db - The database to import into.
* @returns {Promise<void>}
*/
async function importData(json, db) {
for (const [storeName, data] of Object.entries(json)) {
await new Promise((resolve, reject) => {
console.log(`Populating ${storeName} with test data`);
const store = db.transaction(storeName, "readwrite").objectStore(storeName);
function putEntry(idx) {
if (idx >= data.length) {
resolve(undefined);
return;
}
const { key, value } = data[idx];
try {
const putReq = store.put(value, key);
putReq.onsuccess = (_) => putEntry(idx + 1);
putReq.onerror = (_) => reject(putReq.error);
} catch (e) {
throw new Error(
`Error populating '${storeName}' with key ${JSON.stringify(key)}, value ${JSON.stringify(
value,
)}: ${e}`,
);
}
}
putEntry(0);
});
}
}
function getPickleAdditionalData(userId, deviceId) {
const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
for (let i = 0; i < userId.length; i++) {
additionalData[i] = userId.charCodeAt(i);
}
additionalData[userId.length] = 124; // "|"
for (let i = 0; i < deviceId.length; i++) {
additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
}
return additionalData;
}
/** Save an entry to the `matrix-react-sdk` indexeddb database.
*
* If `matrix-react-sdk` does not yet exist, it will be created with the correct schema.
*
* @param {String} table
* @param {String} key
* @param {String} data
* @returns {Promise<void>}
*/
async function idbSave(table, key, data) {
const idb = await new Promise((resolve, reject) => {
const request = indexedDB.open("matrix-react-sdk", 1);
request.onerror = reject;
request.onsuccess = () => {
resolve(request.result);
};
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore("pickleKey");
db.createObjectStore("account");
};
});
return await new Promise((resolve, reject) => {
const txn = idb.transaction([table], "readwrite");
txn.onerror = reject;
const objectStore = txn.objectStore(table);
const request = objectStore.put(data, key);
request.onerror = reject;
request.onsuccess = resolve;
});
}
/**
* Save the pickle key to indexeddb, so that the app can read it.
*
* @param {String} userId - The user's ID (used in the encryption algorithm).
* @param {String} deviceId - The user's device ID (ditto).
* @returns {Promise<void>}
*/
async function savePickleKey(userId, deviceId) {
const itFunc = function* () {
const decoded = atob(PICKLE_KEY);
for (let i = 0; i < decoded.length; ++i) {
yield decoded.charCodeAt(i);
}
};
const decoded = Uint8Array.from(itFunc());
const cryptoKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
const iv = new Uint8Array(32);
crypto.getRandomValues(iv);
const additionalData = getPickleAdditionalData(userId, deviceId);
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, cryptoKey, decoded);
await idbSave("pickleKey", [userId, deviceId], { encrypted, iv, cryptoKey });
}
async function loadDump() {
const dump = await fetch("dump.json");
const indexedDbDump = await dump.json();
await populateStore(indexedDbDump, "matrix-js-sdk:crypto");
await savePickleKey(window.localStorage.getItem("mx_user_id"), window.localStorage.getItem("mx_device_id"));
console.log("Test data loaded; redirecting to main app");
window.location.replace("/");
}
loadDump();

View file

@ -0,0 +1,137 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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

@ -0,0 +1,390 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { expect, JSHandle, type Page } from "@playwright/test";
import type { CryptoEvent, ICreateRoomOpts, MatrixClient } from "matrix-js-sdk/src/matrix";
import type {
EmojiMapping,
ShowSasCallbacks,
VerificationRequest,
Verifier,
VerifierEvent,
} from "matrix-js-sdk/src/crypto-api";
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
*
* @param client - matrix client handle we expect to receive a request
*/
export async function waitForVerificationRequest(client: Client): Promise<JSHandle<VerificationRequest>> {
return client.evaluateHandle((cli) => {
return new Promise<VerificationRequest>((resolve) => {
const onVerificationRequestEvent = async (request: VerificationRequest) => {
await request.accept();
resolve(request);
};
cli.once(
"crypto.verificationRequestReceived" as CryptoEvent.VerificationRequestReceived,
onVerificationRequestEvent,
);
});
});
}
/**
* Automatically handle a SAS verification
*
* Given a verifier which has already been started, wait for the emojis to be received, blindly confirm they
* match, and return them
*
* @param verifier - verifier
* @returns A promise that resolves, with the emoji list, once we confirm the emojis
*/
export function handleSasVerification(verifier: JSHandle<Verifier>): Promise<EmojiMapping[]> {
return verifier.evaluate((verifier) => {
const event = verifier.getShowSasCallbacks();
if (event) return event.sas.emoji;
return new Promise<EmojiMapping[]>((resolve) => {
const onShowSas = (event: ShowSasCallbacks) => {
verifier.off("show_sas" as VerifierEvent, onShowSas);
event.confirm();
resolve(event.sas.emoji);
};
verifier.on("show_sas" as VerifierEvent, onShowSas);
});
});
}
/**
* Check that the user has published cross-signing keys, and that the user's device has been cross-signed.
*/
export async function checkDeviceIsCrossSigned(app: ElementAppPage): Promise<void> {
const { userId, deviceId, keys } = await app.client.evaluate(async (cli: MatrixClient) => {
const deviceId = cli.getDeviceId();
const userId = cli.getUserId();
const keys = await cli.downloadKeysForUsers([userId]);
return { userId, deviceId, keys };
});
// there should be three cross-signing keys
expect(keys.master_keys[userId]).toHaveProperty("keys");
expect(keys.self_signing_keys[userId]).toHaveProperty("keys");
expect(keys.user_signing_keys[userId]).toHaveProperty("keys");
// and the device should be signed by the self-signing key
const selfSigningKeyId = Object.keys(keys.self_signing_keys[userId].keys)[0];
expect(keys.device_keys[userId][deviceId]).toBeDefined();
const myDeviceSignatures = keys.device_keys[userId][deviceId].signatures[userId];
expect(myDeviceSignatures[selfSigningKeyId]).toBeDefined();
}
/**
* Check that the current device is connected to the expected key backup.
* Also checks that the decryption key is known and cached locally.
*
* @param page - the page to check
* @param expectedBackupVersion - the version of the backup we expect to be connected to.
* @param checkBackupKeyInCache - whether to check that the backup key is cached locally.
*/
export async function checkDeviceIsConnectedKeyBackup(
page: Page,
expectedBackupVersion: string,
checkBackupKeyInCache: boolean,
): Promise<void> {
// Sanity check the given backup version: if it's null, something went wrong earlier in the test.
if (!expectedBackupVersion) {
throw new Error(
`Invalid backup version passed to \`checkDeviceIsConnectedKeyBackup\`: ${expectedBackupVersion}`,
);
}
await page.getByRole("button", { name: "User menu" }).click();
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Security & Privacy" }).click();
await expect(page.locator(".mx_Dialog").getByRole("button", { name: "Restore from Backup" })).toBeVisible();
// expand the advanced section to see the active version in the reports
await page.locator(".mx_SecureBackupPanel_advanced").locator("..").click();
if (checkBackupKeyInCache) {
const cacheDecryptionKeyStatusElement = page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(2) td");
await expect(cacheDecryptionKeyStatusElement).toHaveText("cached locally, well formed");
}
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
expectedBackupVersion + " (Algorithm: m.megolm_backup.v1.curve25519-aes-sha2)",
);
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(expectedBackupVersion);
}
/**
* Fill in the login form in element with the given creds.
*
* If a `securityKey` is given, verifies the new device using the key.
*/
export async function logIntoElement(
page: Page,
homeserver: HomeserverInstance,
credentials: Credentials,
securityKey?: string,
) {
await page.goto("/#/login");
// select homeserver
await page.getByRole("button", { name: "Edit" }).click();
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl);
await page.getByRole("button", { name: "Continue", exact: true }).click();
// wait for the dialog to go away
await expect(page.locator(".mx_ServerPickerDialog")).not.toBeVisible();
await page.getByRole("textbox", { name: "Username" }).fill(credentials.userId);
await page.getByPlaceholder("Password").fill(credentials.password);
await page.getByRole("button", { name: "Sign in" }).click();
// if a securityKey was given, verify the new device
if (securityKey !== undefined) {
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key" }).click();
// Fill in the security key
await page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
await page.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
await page.getByRole("button", { name: "Done" }).click();
}
}
/**
* Click the "sign out" option in Element, and wait for the login page to load
*
* @param page - Playwright `Page` object.
* @param discardKeys - if true, expect a "You'll lose access to your encrypted messages" dialog, and dismiss it.
*/
export async function logOutOfElement(page: Page, discardKeys: boolean = false) {
await page.getByRole("button", { name: "User menu" }).click();
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
if (discardKeys) {
await page.getByRole("button", { name: "I don't want my encrypted messages" }).click();
} else {
await page.locator(".mx_Dialog .mx_QuestionDialog").getByRole("button", { name: "Sign out" }).click();
}
// Wait for the login page to load
await page.getByRole("heading", { name: "Sign in" }).click();
}
/**
* Open the security settings, and verify the current session using the security key.
*
* @param app - `ElementAppPage` wrapper for the playwright `Page`.
* @param securityKey - The security key (i.e., 4S key), set up during a previous session.
*/
export async function verifySession(app: ElementAppPage, securityKey: string) {
const settings = await app.settings.openUserSettings("Security & Privacy");
await settings.getByRole("button", { name: "Verify this session" }).click();
await app.page.getByRole("button", { name: "Verify with Security Key" }).click();
await app.page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
await app.page.getByRole("button", { name: "Continue", disabled: false }).click();
await app.page.getByRole("button", { name: "Done" }).click();
}
/**
* Given a SAS verifier for a bot client:
* - wait for the bot to receive the emojis
* - check that the bot sees the same emoji as the application
*
* @param verifier - a verifier in a bot client
*/
export async function doTwoWaySasVerification(page: Page, verifier: JSHandle<Verifier>): Promise<void> {
// on the bot side, wait for the emojis, confirm they match, and return them
const emojis = await handleSasVerification(verifier);
const emojiBlocks = page.locator(".mx_VerificationShowSas_emojiSas_block");
await expect(emojiBlocks).toHaveCount(emojis.length);
// then, check that our application shows an emoji panel with the same emojis.
for (let i = 0; i < emojis.length; i++) {
const emoji = emojis[i];
const emojiBlock = emojiBlocks.nth(i);
const textContent = await emojiBlock.textContent();
// VerificationShowSas munges the case of the emoji descriptions returned by the js-sdk before
// displaying them. Once we drop support for legacy crypto, that code can go away, and so can the
// case-munging here.
expect(textContent.toLowerCase()).toEqual(emoji[0] + emoji[1].toLowerCase());
}
}
/**
* Open the security settings and enable secure key backup.
*
* Assumes that the current device has been cross-signed (which means that we skip a step where we set it up).
*
* Returns the security key
*/
export async function enableKeyBackup(app: ElementAppPage): Promise<string> {
await app.settings.openUserSettings("Security & Privacy");
await app.page.getByRole("button", { name: "Set up Secure Backup" }).click();
const dialog = app.page.locator(".mx_Dialog");
// Recovery key is selected by default
await dialog.getByRole("button", { name: "Continue" }).click({ timeout: 60000 });
// copy the text ourselves
const securityKey = await dialog.locator(".mx_CreateSecretStorageDialog_recoveryKey code").textContent();
await copyAndContinue(app.page);
await expect(dialog.getByText("Secure Backup successful")).toBeVisible();
await dialog.getByRole("button", { name: "Done" }).click();
await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible();
return securityKey;
}
/**
* Click on copy and continue buttons to dismiss the security key dialog
*/
export async function copyAndContinue(page: Page) {
await page.getByRole("button", { name: "Copy" }).click();
await page.getByRole("button", { name: "Continue" }).click();
}
/**
* Create a shared, unencrypted room with the given user, and wait for them to join
*
* @param other - UserID of the other user
* @param opts - other options for the createRoom call
*
* @returns a promise which resolves to the room ID
*/
export async function createSharedRoomWithUser(
app: ElementAppPage,
other: string,
opts: Omit<ICreateRoomOpts, "invite"> = { name: "TestRoom" },
): Promise<string> {
const roomId = await app.client.createRoom({ ...opts, invite: [other] });
await app.viewRoomById(roomId);
// wait for the other user to join the room, otherwise our attempt to open his user details may race
// with his join.
await expect(app.page.getByText(" joined the room", { exact: false })).toBeVisible();
return roomId;
}
/**
* Send a message in the current room
* @param page
* @param message - The message text to send
*/
export async function sendMessageInCurrentRoom(page: Page, message: string): Promise<void> {
await page.locator(".mx_MessageComposer").getByRole("textbox").fill(message);
await page.getByTestId("sendmessagebtn").click();
}
/**
* Create a room with the given name and encryption status using the room creation dialog.
*
* @param roomName - The name of the room to create
* @param isEncrypted - Whether the room should be encrypted
*/
export async function createRoom(page: Page, roomName: string, isEncrypted: boolean): Promise<void> {
await page.getByRole("button", { name: "Add room" }).click();
await page.locator(".mx_IconizedContextMenu").getByRole("menuitem", { name: "New room" }).click();
const dialog = page.locator(".mx_Dialog");
await dialog.getByLabel("Name").fill(roomName);
if (!isEncrypted) {
// it's enabled by default
await page.getByLabel("Enable end-to-end encryption").click();
}
await dialog.getByRole("button", { name: "Create room" }).click();
// Wait for the client to process the encryption event before carrying on (and potentially sending events).
if (isEncrypted) {
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_RightPanel").getByRole("menuitem", { name: "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;
});
}
/** Log in a second device for the given bot user */
export async function createSecondBotDevice(page: Page, homeserver: HomeserverInstance, bob: Bot) {
const bobSecondDevice = new Bot(page, homeserver, {
bootstrapSecretStorage: false,
bootstrapCrossSigning: false,
});
bobSecondDevice.setCredentials(await homeserver.loginUser(bob.credentials.userId, bob.credentials.password));
await bobSecondDevice.prepareClient();
return bobSecondDevice;
}

View file

@ -0,0 +1,366 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Locator, Page } from "@playwright/test";
import type { EventType, IContent, ISendEventResponse, MsgType, Visibility } from "matrix-js-sdk/src/matrix";
import { expect, test } from "../../element-web-test";
import { ElementAppPage } from "../../pages/ElementAppPage";
import { SettingLevel } from "../../../src/settings/SettingLevel";
async function sendEvent(app: ElementAppPage, roomId: string): Promise<ISendEventResponse> {
return app.client.sendEvent(roomId, null, "m.room.message" as EventType, {
msgtype: "m.text" as MsgType,
body: "Message",
});
}
/** generate a message event which will take up some room on the page. */
function mkPadding(n: number): IContent {
return {
msgtype: "m.text" as MsgType,
body: `padding ${n}`,
format: "org.matrix.custom.html",
formatted_body: `<h3>Test event ${n}</h3>\n`.repeat(10),
};
}
test.describe("Editing", () => {
// Edit "Message"
const editLastMessage = async (page: Page, edit: string) => {
const eventTile = page.locator(".mx_RoomView_MessageList .mx_EventTile_last");
await eventTile.hover();
await eventTile.getByRole("button", { name: "Edit" }).click();
const textbox = page.getByRole("textbox", { name: "Edit message" });
await textbox.fill(edit);
await textbox.press("Enter");
};
const clickEditedMessage = async (page: Page, edited: string) => {
// Assert that the message was edited
const eventTile = page.locator(".mx_EventTile", { hasText: edited });
await expect(eventTile).toBeVisible();
// Click to display the message edit history dialog
await eventTile.getByText("(edited)").click();
};
const clickButtonViewSource = async (locator: Locator) => {
const eventTile = locator.locator(".mx_EventTile_line");
await eventTile.hover();
// Assert that "View Source" button is rendered and click it
await eventTile.getByRole("button", { name: "View Source" }).click();
};
test.use({
displayName: "Edith",
room: async ({ user, app }, use) => {
const roomId = await app.client.createRoom({ name: "Test room" });
await use({ roomId });
},
botCreateOpts: { displayName: "Bob" },
});
test("should render and interact with the message edit history dialog", async ({ page, user, app, room }) => {
// Click the "Remove" button on the message edit history dialog
const clickButtonRemove = async (locator: Locator) => {
const eventTileLine = locator.locator(".mx_EventTile_line");
await eventTileLine.hover();
await eventTileLine.getByRole("button", { name: "Remove" }).click();
};
await page.goto(`#/room/${room.roomId}`);
// Send "Message"
await sendEvent(app, room.roomId);
// Edit "Message" to "Massage"
await editLastMessage(page, "Massage");
// Assert that the edit label is visible
await expect(page.locator(".mx_EventTile_edited")).toBeVisible();
await clickEditedMessage(page, "Massage");
// Assert that the message edit history dialog is rendered
const dialog = page.getByRole("dialog");
const li = dialog.getByRole("listitem").last();
// Assert CSS styles which are difficult or cannot be detected with snapshots are applied as expected
await expect(li).toHaveCSS("clear", "both");
const timestamp = li.locator(".mx_EventTile .mx_MessageTimestamp");
await expect(timestamp).toHaveCSS("position", "absolute");
await expect(timestamp).toHaveCSS("inset-inline-start", "0px");
await expect(timestamp).toHaveCSS("text-align", "center");
// Assert that monospace characters can fill the content line as expected
await expect(li.locator(".mx_EventTile .mx_EventTile_content")).toHaveCSS("margin-inline-end", "0px");
// Assert that zero block start padding is applied to mx_EventTile as expected
// See: .mx_EventTile on _EventTile.pcss
await expect(li.locator(".mx_EventTile")).toHaveCSS("padding-block-start", "0px");
// Assert that the date separator is rendered at the top
await expect(dialog.getByRole("listitem").first().locator("h2", { hasText: "today" })).toHaveCSS(
"text-transform",
"capitalize",
);
{
// Assert that the edited message is rendered under the date separator
const tile = dialog.locator("li:nth-child(2) .mx_EventTile");
// Assert that the edited message body consists of both deleted character and inserted character
// Above the first "e" of "Message" was replaced with "a"
await expect(tile.locator(".mx_EventTile_body")).toHaveText("Meassage");
const body = tile.locator(".mx_EventTile_content .mx_EventTile_body");
await expect(body.locator(".mx_EditHistoryMessage_deletion").getByText("e")).toBeVisible();
await expect(body.locator(".mx_EditHistoryMessage_insertion").getByText("a")).toBeVisible();
}
// Assert that the original message is rendered at the bottom
await expect(
dialog
.locator("li:nth-child(3) .mx_EventTile")
.locator(".mx_EventTile_content .mx_EventTile_body", { hasText: "Message" }),
).toBeVisible();
// Take a snapshot of the dialog
await expect(dialog).toMatchScreenshot("message-edit-history-dialog.png", {
mask: [page.locator(".mx_MessageTimestamp")],
});
{
const tile = dialog.locator("li:nth-child(2) .mx_EventTile");
await expect(tile.locator(".mx_EventTile_body")).toHaveText("Meassage");
// Click the "Remove" button again
await clickButtonRemove(tile);
}
// Do nothing and close the dialog to confirm that the message edit history dialog is rendered
await app.closeDialog();
{
// Assert that the message edit history dialog is rendered again after it was closed
const tile = dialog.locator("li:nth-child(2) .mx_EventTile");
await expect(tile.locator(".mx_EventTile_body")).toHaveText("Meassage");
// Click the "Remove" button again
await clickButtonRemove(tile);
}
// This time remove the message really
const textInputDialog = page.locator(".mx_TextInputDialog");
await textInputDialog.getByRole("textbox", { name: "Reason (optional)" }).fill("This is a test."); // Reason
await textInputDialog.getByRole("button", { name: "Remove" }).click();
// Assert that the message edit history dialog is rendered again
const messageEditHistoryDialog = page.locator(".mx_MessageEditHistoryDialog");
// Assert that the date is rendered
await expect(
messageEditHistoryDialog.getByRole("listitem").first().locator("h2", { hasText: "today" }),
).toHaveCSS("text-transform", "capitalize");
// Assert that the original message is rendered under the date on the dialog
await expect(
messageEditHistoryDialog
.locator("li:nth-child(2) .mx_EventTile")
.locator(".mx_EventTile_content .mx_EventTile_body", { hasText: "Message" }),
).toBeVisible();
// Assert that the edited message is gone
await expect(
messageEditHistoryDialog.locator(".mx_EventTile_content .mx_EventTile_body", { hasText: "Meassage" }),
).not.toBeVisible();
await app.closeDialog();
// Assert that the redaction placeholder is rendered
await expect(
page
.locator(".mx_RoomView_MessageList")
.locator(".mx_EventTile_last .mx_RedactedBody", { hasText: "Message deleted" }),
).toBeVisible();
});
test("should render 'View Source' button in developer mode on the message edit history dialog", async ({
page,
user,
app,
room,
}) => {
await page.goto(`#/room/${room.roomId}`);
// Send "Message"
await sendEvent(app, room.roomId);
// Edit "Message" to "Massage"
await editLastMessage(page, "Massage");
// Assert that the edit label is visible
await expect(page.locator(".mx_EventTile_edited")).toBeVisible();
await clickEditedMessage(page, "Massage");
{
const dialog = page.getByRole("dialog");
// Assert that the original message is rendered
const li = dialog.locator("li:nth-child(3)");
// Assert that "View Source" is not rendered
const eventLine = li.locator(".mx_EventTile_line");
await eventLine.hover();
await expect(eventLine.getByRole("button", { name: "View Source" })).not.toBeVisible();
}
await app.closeDialog();
// Enable developer mode
await app.settings.setValue("developerMode", null, SettingLevel.ACCOUNT, true);
await clickEditedMessage(page, "Massage");
{
const dialog = page.getByRole("dialog");
{
// Assert that the edited message is rendered
const li = dialog.locator("li:nth-child(2)");
// Assert that "Remove" button for the original message is rendered
const line = li.locator(".mx_EventTile_line");
await line.hover();
await expect(line.getByRole("button", { name: "Remove" })).toBeVisible();
await clickButtonViewSource(li);
}
// Assert that view source dialog is rendered and close the dialog
await app.closeDialog();
{
// Assert that the original message is rendered
const li = dialog.locator("li:nth-child(3)");
// Assert that "Remove" button for the original message does not exist
const line = li.locator(".mx_EventTile_line");
await line.hover();
await expect(line.getByRole("button", { name: "Remove" })).not.toBeVisible();
await clickButtonViewSource(li);
}
// Assert that view source dialog is rendered and close the dialog
await app.closeDialog();
}
});
test("should close the composer when clicking save after making a change and undoing it", async ({
page,
user,
app,
room,
axe,
checkA11y,
}) => {
axe.disableRules("color-contrast"); // XXX: We have some known contrast issues here
axe.exclude(".mx_Tooltip_visible"); // XXX: this is fine but would be good to fix
await page.goto(`#/room/${room.roomId}`);
await sendEvent(app, room.roomId);
{
// Edit message
const tile = page.locator(".mx_RoomView_body .mx_EventTile").last();
await expect(tile.getByText("Message", { exact: true })).toBeVisible();
const line = tile.locator(".mx_EventTile_line");
await line.hover();
await line.getByRole("button", { name: "Edit" }).click();
await checkA11y();
const editComposer = page.getByRole("textbox", { name: "Edit message" });
await editComposer.pressSequentially("Foo");
await editComposer.press("Backspace");
await editComposer.press("Backspace");
await editComposer.press("Backspace");
await editComposer.press("Enter");
await app.getComposerField().hover(); // XXX: move the hover to get rid of the "Edit" tooltip
await checkA11y();
}
await expect(
page.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", { hasText: "Message" }),
).toBeVisible();
// Assert that the edit composer has gone away
await expect(page.getByRole("textbox", { name: "Edit message" })).not.toBeVisible();
});
test("should correctly display events which are edited, where we lack the edit event", async ({
page,
user,
app,
axe,
checkA11y,
bot: bob,
}) => {
// This tests the behaviour when a message has been edited some time after it has been sent, and we
// jump back in room history to view the event, but do not have the actual edit event.
//
// In that scenario, we rely on the server to replace the content (pre-MSC3925), or do it ourselves based on
// the bundled edit event (post-MSC3925).
//
// To test it, we need to have a room with lots of events in, so we can jump around the timeline without
// paginating in the event itself. Hence, we create a bot user which creates the room and populates it before
// we join.
// "bob" now creates the room, and sends a load of events in it. Note that all of this happens via calls on
// the js-sdk rather than Cypress commands, so uses regular async/await.
const testRoomId = await bob.createRoom({ name: "TestRoom", visibility: "public" as Visibility });
const { event_id: originalEventId } = await bob.sendMessage(testRoomId, {
body: "original",
msgtype: "m.text",
});
// send a load of padding events. We make them large, so that they fill the whole screen
// and the client doesn't end up paginating into the event we want.
let i = 0;
while (i < 10) {
await bob.sendMessage(testRoomId, mkPadding(i++));
}
// ... then the edit ...
const editEventId = (
await bob.sendMessage(testRoomId, {
"m.new_content": { body: "Edited body", msgtype: "m.text" },
"m.relates_to": {
rel_type: "m.replace",
event_id: originalEventId,
},
"body": "* edited",
"msgtype": "m.text",
})
).event_id;
// ... then a load more padding ...
while (i < 20) {
await bob.sendMessage(testRoomId, mkPadding(i++));
}
// now have the cypress user join the room, jump to the original event, and wait for the event to be visible
await app.client.joinRoom(testRoomId);
await app.viewRoomByName("TestRoom");
await page.goto(`#/room/${testRoomId}/${originalEventId}`);
const messageTile = page.locator(`[data-event-id="${originalEventId}"]`);
// at this point, the edit event should still be unknown
const timeline = await app.client.evaluate(
(cli, { testRoomId, editEventId }) => cli.getRoom(testRoomId).getTimelineForEvent(editEventId),
{ testRoomId, editEventId },
);
expect(timeline).toBeNull();
// nevertheless, the event should be updated
await expect(messageTile.locator(".mx_EventTile_body")).toHaveText("Edited body");
await expect(messageTile.locator(".mx_EventTile_edited")).toBeVisible();
});
});

View file

@ -0,0 +1,37 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Image Upload", () => {
test.use({
displayName: "Alice",
});
test.beforeEach(async ({ page, app, user }) => {
await app.client.createRoom({ name: "My Pictures" });
await app.viewRoomByName("My Pictures");
// Wait until configuration is finished
await expect(
page
.locator(".mx_GenericEventListSummary[data-layout='group'] .mx_GenericEventListSummary_summary")
.getByText(`${user.displayName} created and configured the room.`),
).toBeVisible();
});
test("should show image preview when uploading an image", async ({ page, app }) => {
await page
.locator(".mx_MessageComposer_actions input[type='file']")
.setInputFiles("playwright/sample-files/riot.png");
await expect(page.getByRole("button", { name: "Upload" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Close dialog" })).toBeEnabled();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("image-upload-preview.png");
});
});

View file

@ -0,0 +1,69 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { expect, test } from "../../element-web-test";
import { selectHomeserver } from "../utils";
const username = "user1234";
// this has to be password-like enough to please zxcvbn. Needless to say it's just from pwgen.
const password = "oETo7MPf0o";
const email = "user@nowhere.dummy";
test.describe("Forgot Password", () => {
test.use({
startHomeserverOpts: ({ mailhog }, use) =>
use({
template: "email",
variables: {
SMTP_HOST: "host.containers.internal",
SMTP_PORT: mailhog.instance.smtpPort,
},
}),
});
test("renders properly", async ({ page, homeserver }) => {
await page.goto("/");
await page.getByRole("link", { name: "Sign in" }).click();
// need to select a homeserver at this stage, before entering the forgot password flow
await selectHomeserver(page, homeserver.config.baseUrl);
await page.getByRole("button", { name: "Forgot password?" }).click();
await expect(page.getByRole("main")).toMatchScreenshot("forgot-password.png");
});
test("renders email verification dialog properly", async ({ page, homeserver }) => {
const user = await homeserver.registerUser(username, password);
await homeserver.setThreepid(user.userId, "email", email);
await page.goto("/");
await page.getByRole("link", { name: "Sign in" }).click();
await selectHomeserver(page, homeserver.config.baseUrl);
await page.getByRole("button", { name: "Forgot password?" }).click();
await page.getByRole("textbox", { name: "Email address" }).fill(email);
await page.getByRole("button", { name: "Send email" }).click();
await page.getByRole("button", { name: "Next" }).click();
await page.getByRole("textbox", { name: "New Password", exact: true }).fill(password);
await page.getByRole("textbox", { name: "Confirm new password", exact: true }).fill(password);
await page.getByRole("button", { name: "Reset password" }).click();
await expect(page.getByRole("button", { name: "Resend" })).toBeInViewport();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("forgot-password-verify-email.png");
});
});

View file

@ -0,0 +1,120 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import type { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { openIntegrationManager } from "./utils";
const ROOM_NAME = "Integration Manager Test";
const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal";
const INTEGRATION_MANAGER_HTML = `
<html lang="en">
<head>
<title>Fake Integration Manager</title>
</head>
<body>
<button name="Send" id="send-action">Press to send action</button>
<button name="Close" id="close">Press to close</button>
<p id="message-response">No response</p>
<script>
document.getElementById("send-action").onclick = () => {
window.parent.postMessage(
{
action: "get_open_id_token",
},
'*',
);
};
document.getElementById("close").onclick = () => {
window.parent.postMessage(
{
action: "close_scalar",
},
'*',
);
};
// Listen for a postmessage response
window.addEventListener("message", (event) => {
document.getElementById("message-response").innerText = JSON.stringify(event.data);
});
</script>
</body>
</html>
`;
async function sendActionFromIntegrationManager(page: Page, integrationManagerUrl: string) {
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await iframe.getByRole("button", { name: "Press to send action" }).click();
}
test.describe("Integration Manager: Get OpenID Token", () => {
test.use({
displayName: "Alice",
room: async ({ user, app }, use) => {
const roomId = await app.client.createRoom({
name: ROOM_NAME,
});
await use({ roomId });
},
});
let integrationManagerUrl: string;
test.beforeEach(async ({ page, webserver }) => {
integrationManagerUrl = webserver.start(INTEGRATION_MANAGER_HTML);
await page.addInitScript(
({ token, integrationManagerUrl }) => {
window.localStorage.setItem("mx_scalar_token", token);
window.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, token);
},
{
token: INTEGRATION_MANAGER_TOKEN,
integrationManagerUrl,
},
);
});
test.beforeEach(async ({ page, user, app, room }) => {
await app.client.setAccountData("m.widgets", {
"m.integration_manager": {
content: {
type: "m.integration_manager",
name: "Integration Manager",
url: integrationManagerUrl,
data: {
api_url: integrationManagerUrl,
},
},
id: "integration-manager",
},
});
// Succeed when checking the token is valid
await page.route(
`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`,
async (route) => {
await route.fulfill({
json: {
user_id: user.userId,
},
});
},
);
await app.viewRoomByName(ROOM_NAME);
});
test("should successfully obtain an openID token", async ({ page, app }) => {
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl);
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await expect(iframe.locator("#message-response").getByText(/access_token/)).toBeVisible();
});
});

View file

@ -0,0 +1,218 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import type { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { openIntegrationManager } from "./utils";
const ROOM_NAME = "Integration Manager Test";
const USER_DISPLAY_NAME = "Alice";
const BOT_DISPLAY_NAME = "Bob";
const KICK_REASON = "Goodbye";
const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal";
const INTEGRATION_MANAGER_HTML = `
<html lang="en">
<head>
<title>Fake Integration Manager</title>
</head>
<body>
<input type="text" id="target-room-id"/>
<input type="text" id="target-user-id"/>
<button name="Send" id="send-action">Press to send action</button>
<button name="Close" id="close">Press to close</button>
<script>
document.getElementById("send-action").onclick = () => {
window.parent.postMessage(
{
action: "kick",
room_id: document.getElementById("target-room-id").value,
user_id: document.getElementById("target-user-id").value,
reason: "${KICK_REASON}",
},
'*',
);
};
document.getElementById("close").onclick = () => {
window.parent.postMessage(
{
action: "close_scalar",
},
'*',
);
};
</script>
</body>
</html>
`;
async function closeIntegrationManager(page: Page, integrationManagerUrl: string) {
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await iframe.getByRole("button", { name: "Press to close" }).click();
}
async function sendActionFromIntegrationManager(
page: Page,
integrationManagerUrl: string,
targetRoomId: string,
targetUserId: string,
) {
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await iframe.locator("#target-room-id").fill(targetRoomId);
await iframe.locator("#target-user-id").fill(targetUserId);
await iframe.getByRole("button", { name: "Press to send action" }).click();
}
async function clickUntilGone(page: Page, selector: string, attempt = 0) {
if (attempt === 11) {
throw new Error("clickUntilGone attempt count exceeded");
}
await page.locator(selector).last().click();
const count = await page.locator(selector).count();
if (count > 0) {
return clickUntilGone(page, selector, ++attempt);
}
}
async function expectKickedMessage(page: Page, shouldExist: boolean) {
// Expand any event summaries, we can't use a click multiple here because clicking one might de-render others
// This is quite horrible but seems the most stable way of clicking 0-N buttons,
// one at a time with a full re-evaluation after each click
await clickUntilGone(page, ".mx_GenericEventListSummary_toggle[aria-expanded=false]");
// Check for the event message (or lack thereof)
await expect(page.getByText(`${USER_DISPLAY_NAME} removed ${BOT_DISPLAY_NAME}: ${KICK_REASON}`)).toBeVisible({
visible: shouldExist,
});
}
test.describe("Integration Manager: Kick", () => {
test.use({
displayName: "Alice",
room: async ({ user, app }, use) => {
const roomId = await app.client.createRoom({
name: ROOM_NAME,
});
await use({ roomId });
},
botCreateOpts: {
displayName: BOT_DISPLAY_NAME,
autoAcceptInvites: true,
},
});
let integrationManagerUrl: string;
test.beforeEach(async ({ page, webserver }) => {
integrationManagerUrl = webserver.start(INTEGRATION_MANAGER_HTML);
await page.addInitScript(
({ token, integrationManagerUrl }) => {
window.localStorage.setItem("mx_scalar_token", token);
window.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, token);
},
{
token: INTEGRATION_MANAGER_TOKEN,
integrationManagerUrl,
},
);
});
test.beforeEach(async ({ page, user, app, room }) => {
await app.client.setAccountData("m.widgets", {
"m.integration_manager": {
content: {
type: "m.integration_manager",
name: "Integration Manager",
url: integrationManagerUrl,
data: {
api_url: integrationManagerUrl,
},
},
id: "integration-manager",
},
});
// Succeed when checking the token is valid
await page.route(
`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`,
async (route) => {
await route.fulfill({
json: {
user_id: user.userId,
},
});
},
);
await app.viewRoomByName(ROOM_NAME);
});
test("should kick the target", async ({ page, app, bot: targetUser, room }) => {
await app.viewRoomByName(ROOM_NAME);
await app.client.inviteUser(room.roomId, targetUser.credentials.userId);
await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible();
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, true);
});
test("should not kick the target if lacking permissions", async ({ page, app, user, bot: targetUser, room }) => {
await app.viewRoomByName(ROOM_NAME);
await app.client.inviteUser(room.roomId, targetUser.credentials.userId);
await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible();
await app.client.sendStateEvent(room.roomId, "m.room.power_levels", {
kick: 50,
users: {
[user.userId]: 0,
},
});
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, false);
});
test("should no-op if the target already left", async ({ page, app, bot: targetUser, room }) => {
await app.viewRoomByName(ROOM_NAME);
await app.client.inviteUser(room.roomId, targetUser.credentials.userId);
await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible();
await targetUser.leave(room.roomId);
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, false);
});
test("should no-op if the target was banned", async ({ page, app, bot: targetUser, room }) => {
await app.viewRoomByName(ROOM_NAME);
await app.client.inviteUser(room.roomId, targetUser.credentials.userId);
await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible();
await app.client.ban(room.roomId, targetUser.credentials.userId);
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, false);
});
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(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, false);
});
});

View file

@ -0,0 +1,225 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import type { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { openIntegrationManager } from "./utils";
const ROOM_NAME = "Integration Manager Test";
const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal";
const INTEGRATION_MANAGER_HTML = `
<html lang="en">
<head>
<title>Fake Integration Manager</title>
</head>
<body>
<input type="text" id="target-room-id"/>
<input type="text" id="event-type"/>
<input type="text" id="state-key"/>
<button name="Send" id="send-action">Press to send action</button>
<button name="Close" id="close">Press to close</button>
<p id="message-response">No response</p>
<script>
document.getElementById("send-action").onclick = () => {
window.parent.postMessage(
{
action: "read_events",
room_id: document.getElementById("target-room-id").value,
type: document.getElementById("event-type").value,
state_key: JSON.parse(document.getElementById("state-key").value),
},
'*',
);
};
document.getElementById("close").onclick = () => {
window.parent.postMessage(
{
action: "close_scalar",
},
'*',
);
};
// Listen for a postmessage response
window.addEventListener("message", (event) => {
document.getElementById("message-response").innerText = JSON.stringify(event.data);
});
</script>
</body>
</html>
`;
async function sendActionFromIntegrationManager(
page: Page,
integrationManagerUrl: string,
targetRoomId: string,
eventType: string,
stateKey: string | boolean,
) {
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await iframe.locator("#target-room-id").fill(targetRoomId);
await iframe.locator("#event-type").fill(eventType);
await iframe.locator("#state-key").fill(JSON.stringify(stateKey));
await iframe.locator("#send-action").click();
}
test.describe("Integration Manager: Read Events", () => {
test.use({
displayName: "Alice",
room: async ({ user, app }, use) => {
const roomId = await app.client.createRoom({
name: ROOM_NAME,
});
await use({ roomId });
},
});
let integrationManagerUrl: string;
test.beforeEach(async ({ page, webserver }) => {
integrationManagerUrl = webserver.start(INTEGRATION_MANAGER_HTML);
await page.addInitScript(
({ token, integrationManagerUrl }) => {
window.localStorage.setItem("mx_scalar_token", token);
window.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, token);
},
{
token: INTEGRATION_MANAGER_TOKEN,
integrationManagerUrl,
},
);
});
test.beforeEach(async ({ page, user, app, room }) => {
await app.client.setAccountData("m.widgets", {
"m.integration_manager": {
content: {
type: "m.integration_manager",
name: "Integration Manager",
url: integrationManagerUrl,
data: {
api_url: integrationManagerUrl,
},
},
id: "integration-manager",
},
});
// Succeed when checking the token is valid
await page.route(
`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`,
async (route) => {
await route.fulfill({
json: {
user_id: user.userId,
},
});
},
);
await app.viewRoomByName(ROOM_NAME);
});
test("should read a state event by state key", async ({ page, app, room }) => {
const eventType = "io.element.integrations.installations";
const eventContent = {
foo: "bar",
};
const stateKey = "state-key-123";
// Send a state event
const sendEventResponse = await app.client.sendStateEvent(room.roomId, eventType, eventContent, stateKey);
await openIntegrationManager(app);
// Read state events
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey);
// Check the response
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await expect(iframe.locator("#message-response")).toContainText(sendEventResponse.event_id);
await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent)}`);
});
test("should read a state event with empty state key", async ({ page, app, room }) => {
const eventType = "io.element.integrations.installations";
const eventContent = {
foo: "bar",
};
const stateKey = "";
// Send a state event
const sendEventResponse = await app.client.sendStateEvent(room.roomId, eventType, eventContent, stateKey);
await openIntegrationManager(app);
// Read state events
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey);
// Check the response
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await expect(iframe.locator("#message-response")).toContainText(sendEventResponse.event_id);
await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent)}`);
});
test("should read state events with any state key", async ({ page, app, room }) => {
const eventType = "io.element.integrations.installations";
const stateKey1 = "state-key-123";
const eventContent1 = {
foo1: "bar1",
};
const stateKey2 = "state-key-456";
const eventContent2 = {
foo2: "bar2",
};
const stateKey3 = "state-key-789";
const eventContent3 = {
foo3: "bar3",
};
// Send state events
const sendEventResponses = await Promise.all([
app.client.sendStateEvent(room.roomId, eventType, eventContent1, stateKey1),
app.client.sendStateEvent(room.roomId, eventType, eventContent2, stateKey2),
app.client.sendStateEvent(room.roomId, eventType, eventContent3, stateKey3),
]);
await openIntegrationManager(app);
// Read state events
await sendActionFromIntegrationManager(
page,
integrationManagerUrl,
room.roomId,
eventType,
true, // Any state key
);
// Check the response
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await expect(iframe.locator("#message-response")).toContainText(sendEventResponses[0].event_id);
await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent1)}`);
await expect(iframe.locator("#message-response")).toContainText(sendEventResponses[1].event_id);
await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent2)}`);
await expect(iframe.locator("#message-response")).toContainText(sendEventResponses[2].event_id);
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, app, room }) => {
const eventType = "com.example.event";
const stateKey = "";
await openIntegrationManager(app);
// Read state events
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey);
// Check the response
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await expect(iframe.locator("#message-response")).toContainText("Failed to read events");
});
});

View file

@ -0,0 +1,247 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import type { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { openIntegrationManager } from "./utils";
const ROOM_NAME = "Integration Manager Test";
const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal";
const INTEGRATION_MANAGER_HTML = `
<html lang="en">
<head>
<title>Fake Integration Manager</title>
</head>
<body>
<input type="text" id="target-room-id"/>
<input type="text" id="event-type"/>
<input type="text" id="state-key"/>
<input type="text" id="event-content"/>
<button name="Send" id="send-action">Press to send action</button>
<button name="Close" id="close">Press to close</button>
<p id="message-response">No response</p>
<script>
document.getElementById("send-action").onclick = () => {
window.parent.postMessage(
{
action: "send_event",
room_id: document.getElementById("target-room-id").value,
type: document.getElementById("event-type").value,
state_key: document.getElementById("state-key").value,
content: JSON.parse(document.getElementById("event-content").value),
},
'*',
);
};
document.getElementById("close").onclick = () => {
window.parent.postMessage(
{
action: "close_scalar",
},
'*',
);
};
// Listen for a postmessage response
window.addEventListener("message", (event) => {
document.getElementById("message-response").innerText = JSON.stringify(event.data);
});
</script>
</body>
</html>
`;
async function sendActionFromIntegrationManager(
page: Page,
integrationManagerUrl: string,
targetRoomId: string,
eventType: string,
stateKey: string,
content: Record<string, unknown>,
) {
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await iframe.locator("#target-room-id").fill(targetRoomId);
await iframe.locator("#event-type").fill(eventType);
if (stateKey) {
await iframe.locator("#state-key").fill(stateKey);
}
await iframe.locator("#event-content").fill(JSON.stringify(content));
await iframe.locator("#send-action").click();
}
test.describe("Integration Manager: Send Event", () => {
test.use({
displayName: "Alice",
room: async ({ user, app }, use) => {
const roomId = await app.client.createRoom({
name: ROOM_NAME,
});
await use({ roomId });
},
});
let integrationManagerUrl: string;
test.beforeEach(async ({ page, webserver }) => {
integrationManagerUrl = webserver.start(INTEGRATION_MANAGER_HTML);
await page.addInitScript(
({ token, integrationManagerUrl }) => {
window.localStorage.setItem("mx_scalar_token", token);
window.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, token);
},
{
token: INTEGRATION_MANAGER_TOKEN,
integrationManagerUrl,
},
);
});
test.beforeEach(async ({ page, user, app, room }) => {
await app.client.setAccountData("m.widgets", {
"m.integration_manager": {
content: {
type: "m.integration_manager",
name: "Integration Manager",
url: integrationManagerUrl,
data: {
api_url: integrationManagerUrl,
},
},
id: "integration-manager",
},
});
// Succeed when checking the token is valid
await page.route(
`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`,
async (route) => {
await route.fulfill({
json: {
user_id: user.userId,
},
});
},
);
await app.viewRoomByName(ROOM_NAME);
await openIntegrationManager(app);
});
test("should send a state event", async ({ page, app, room }) => {
const eventType = "io.element.integrations.installations";
const eventContent = {
foo: "bar",
};
const stateKey = "state-key-123";
// Send the event
await sendActionFromIntegrationManager(
page,
integrationManagerUrl,
room.roomId,
eventType,
stateKey,
eventContent,
);
// Check the response
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await expect(iframe.locator("#message-response")).toContainText("event_id");
// Check the event
const event = await app.client.evaluate(
(cli, { room, eventType, stateKey }) => {
return cli.getStateEvent(room.roomId, eventType, stateKey);
},
{ room, eventType, stateKey },
);
expect(event).toMatchObject(eventContent);
});
test("should send a state event with empty content", async ({ page, app, room }) => {
const eventType = "io.element.integrations.installations";
const eventContent = {};
const stateKey = "state-key-123";
// Send the event
await sendActionFromIntegrationManager(
page,
integrationManagerUrl,
room.roomId,
eventType,
stateKey,
eventContent,
);
// Check the response
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await expect(iframe.locator("#message-response")).toContainText("event_id");
// Check the event
const event = await app.client.evaluate(
(cli, { room, eventType, stateKey }) => {
return cli.getStateEvent(room.roomId, eventType, stateKey);
},
{ room, eventType, stateKey },
);
expect(event).toMatchObject({});
});
test("should send a state event with empty state key", async ({ page, app, room }) => {
const eventType = "io.element.integrations.installations";
const eventContent = {
foo: "bar",
};
const stateKey = "";
// Send the event
await sendActionFromIntegrationManager(
page,
integrationManagerUrl,
room.roomId,
eventType,
stateKey,
eventContent,
);
// Check the response
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await expect(iframe.locator("#message-response")).toContainText("event_id");
// Check the event
const event = await app.client.evaluate(
(cli, { room, eventType, stateKey }) => {
return cli.getStateEvent(room.roomId, eventType, stateKey);
},
{ room, eventType, stateKey },
);
expect(event).toMatchObject(eventContent);
});
test("should fail to send an event type which is not allowed", async ({ page, room }) => {
const eventType = "com.example.event";
const eventContent = {
foo: "bar",
};
const stateKey = "";
// Send the event
await sendActionFromIntegrationManager(
page,
integrationManagerUrl,
room.roomId,
eventType,
stateKey,
eventContent,
);
// Check the response
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);
await expect(iframe.locator("#message-response")).toContainText("Failed to send event");
});
});

View file

@ -0,0 +1,16 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import type { ElementAppPage } from "../../pages/ElementAppPage";
export async function openIntegrationManager(app: ElementAppPage) {
const { page } = app;
await app.toggleRoomInfoPanel();
await page.getByRole("menuitem", { name: "Extensions" }).click();
await page.getByRole("button", { name: "Add extensions" }).click();
}

View file

@ -0,0 +1,124 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Suguru Hirahara
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Invite dialog", function () {
test.use({
displayName: "Hanako",
botCreateOpts: {
displayName: "BotAlice",
},
});
const botName = "BotAlice";
test("should support inviting a user to a room", async ({ page, app, user, bot }) => {
// Create and view a room
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
// Assert that the room was configured
await expect(page.getByText("Hanako created and configured the room.")).toBeVisible();
// Open the room info panel
await app.toggleRoomInfoPanel();
await page.locator(".mx_BaseCard").getByRole("menuitem", { name: "Invite" }).click();
const other = page.locator(".mx_InviteDialog_other");
// Assert that the header is rendered
await expect(
other.locator(".mx_Dialog_header .mx_Dialog_title").getByText("Invite to Test Room"),
).toBeVisible();
// Assert that the bar is rendered
await expect(other.locator(".mx_InviteDialog_addressBar")).toBeVisible();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("invite-dialog-room-without-user.png");
await expect(other.locator(".mx_InviteDialog_identityServer")).not.toBeVisible();
await other.getByTestId("invite-dialog-input").fill(bot.credentials.userId);
// Assert that notification about identity servers appears after typing userId
await expect(other.locator(".mx_InviteDialog_identityServer")).toBeVisible();
// Assert that the bot id is rendered properly
await expect(
other.locator(".mx_InviteDialog_tile_nameStack_userId").getByText(bot.credentials.userId),
).toBeVisible();
await other.locator(".mx_InviteDialog_tile_nameStack_name").getByText(botName).click();
await expect(
other.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText(botName),
).toBeVisible();
// Take a snapshot of the invite dialog with a user pill
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("invite-dialog-room-with-user-pill.png");
// Invite the bot
await other.getByRole("button", { name: "Invite" }).click();
// Assert that the invite dialog disappears
await expect(page.locator(".mx_InviteDialog_other")).not.toBeVisible();
// Assert that they were invited and joined
await expect(page.getByText(`${botName} joined the room`)).toBeVisible();
});
test("should support inviting a user to Direct Messages", async ({ page, app, user, bot }) => {
await page.locator(".mx_RoomList").getByRole("button", { name: "Start chat" }).click();
const other = page.locator(".mx_InviteDialog_other");
// Assert that the header is rendered
await expect(other.locator(".mx_Dialog_header .mx_Dialog_title").getByText("Direct Messages")).toBeVisible();
// Assert that the bar is rendered
await expect(other.locator(".mx_InviteDialog_addressBar")).toBeVisible();
// Take a snapshot of the invite dialog
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("invite-dialog-dm-without-user.png");
await other.getByTestId("invite-dialog-input").fill(bot.credentials.userId);
await expect(other.locator(".mx_InviteDialog_tile_nameStack").getByText(bot.credentials.userId)).toBeVisible();
await other.locator(".mx_InviteDialog_tile_nameStack").getByText(botName).click();
await expect(
other.locator(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name").getByText(botName),
).toBeVisible();
// Take a snapshot of the invite dialog with a user pill
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("invite-dialog-dm-with-user-pill.png");
// Open a direct message UI
await other.getByRole("button", { name: "Go" }).click();
// Assert that the invite dialog disappears
await expect(page.locator(".mx_InviteDialog_other")).not.toBeVisible();
// 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_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]");
await composer.fill("Hello}");
await composer.press("Enter");
// Assert that they were invited and joined
await expect(page.getByText(`${botName} joined the room`)).toBeVisible();
// Assert that the message is displayed at the bottom
await expect(page.locator(".mx_EventTile_last").getByText("Hello")).toBeVisible();
});
});

View file

@ -0,0 +1,84 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { waitForRoom } from "../utils";
import { Filter } from "../../pages/Spotlight";
test.describe("Create Knock Room", () => {
test.use({
displayName: "Alice",
labsFlags: ["feature_ask_to_join"],
});
test("should create a knock room", async ({ page, app, user }) => {
const dialog = await app.openCreateRoomDialog();
await dialog.getByRole("textbox", { name: "Name" }).fill("Cybersecurity");
await dialog.getByRole("button", { name: "Room visibility" }).click();
await dialog.getByRole("option", { name: "Ask to join" }).click();
await dialog.getByRole("button", { name: "Create room" }).click();
await expect(page.locator(".mx_RoomHeader").getByText("Cybersecurity")).toBeVisible();
const urlHash = await page.evaluate(() => window.location.hash);
const roomId = urlHash.replace("#/room/", "");
// Room should have a knock join rule
await waitForRoom(page, app.client, roomId, (room) => {
const events = room.getLiveTimeline().getEvents();
return events.some((e) => e.getType() === "m.room.join_rules" && e.getContent().join_rule === "knock");
});
});
test("should create a room and change a join rule to knock", async ({ page, app, user }) => {
const dialog = await app.openCreateRoomDialog();
await dialog.getByRole("textbox", { name: "Name" }).fill("Cybersecurity");
await dialog.getByRole("button", { name: "Create room" }).click();
await expect(page.locator(".mx_RoomHeader").getByText("Cybersecurity")).toBeVisible();
const urlHash = await page.evaluate(() => window.location.hash);
const roomId = urlHash.replace("#/room/", "");
await app.settings.openRoomSettings("Security & Privacy");
const settingsGroup = page.getByRole("group", { name: "Access" });
await expect(settingsGroup.getByRole("radio", { name: "Private (invite only)" })).toBeChecked();
await settingsGroup.getByText("Ask to join").click();
// Room should have a knock join rule
await waitForRoom(page, app.client, roomId, (room) => {
const events = room.getLiveTimeline().getEvents();
return events.some((e) => e.getType() === "m.room.join_rules" && e.getContent().join_rule === "knock");
});
});
test("should create a public knock room", async ({ page, app, user }) => {
const dialog = await app.openCreateRoomDialog();
await dialog.getByRole("textbox", { name: "Name" }).fill("Cybersecurity");
await dialog.getByRole("button", { name: "Room visibility" }).click();
await dialog.getByRole("option", { name: "Ask to join" }).click();
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_RoomHeader").getByText("Cybersecurity")).toBeVisible();
const urlHash = await page.evaluate(() => window.location.hash);
const roomId = urlHash.replace("#/room/", "");
// Room should have a knock join rule
await waitForRoom(page, app.client, roomId, (room) => {
const events = room.getLiveTimeline().getEvents();
return events.some((e) => e.getType() === "m.room.join_rules" && e.getContent().join_rule === "knock");
});
const spotlightDialog = await app.openSpotlight();
await spotlightDialog.filter(Filter.PublicRooms);
await expect(spotlightDialog.results.nth(0)).toContainText("Cybersecurity");
});
});

View file

@ -0,0 +1,294 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Mikhail Aheichyk
Copyright 2023 Nordeck IT + Consulting GmbH.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { type Visibility } from "matrix-js-sdk/src/matrix";
import { test, expect } from "../../element-web-test";
import { waitForRoom } from "../utils";
import { Filter } from "../../pages/Spotlight";
test.describe("Knock Into Room", () => {
test.use({
displayName: "Alice",
labsFlags: ["feature_ask_to_join"],
botCreateOpts: {
displayName: "Bob",
},
room: async ({ bot }, use) => {
const roomId = await bot.createRoom({
name: "Cybersecurity",
initial_state: [
{
type: "m.room.join_rules",
content: {
join_rule: "knock",
},
state_key: "",
},
],
});
await use({ roomId });
},
});
test("should knock into the room then knock is approved and user joins the room then user is kicked and joins again", async ({
page,
app,
user,
bot,
room,
}) => {
await app.viewRoomById(room.roomId);
const roomPreviewBar = page.locator(".mx_RoomPreviewBar");
await roomPreviewBar.getByRole("button", { name: "Join the discussion" }).click();
await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join?" })).toBeVisible();
await expect(roomPreviewBar.getByRole("textbox")).toBeVisible();
await roomPreviewBar.getByRole("button", { name: "Request access" }).click();
await expect(roomPreviewBar.getByRole("heading", { name: "Request to join sent" })).toBeVisible();
// Knocked room should appear in Rooms
await expect(
page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }),
).toBeVisible();
// bot waits for knock request from Alice
await waitForRoom(page, bot, room.roomId, (room) => {
const events = room.getLiveTimeline().getEvents();
return events.some(
(e) =>
e.getType() === "m.room.member" &&
e.getContent()?.membership === "knock" &&
e.getContent()?.displayname === "Alice",
);
});
// bot invites Alice
await bot.inviteUser(room.roomId, user.userId);
await expect(
page.getByRole("group", { name: "Invites" }).getByRole("treeitem", { name: "Cybersecurity" }),
).toBeVisible();
// Alice have to accept invitation in order to join the room.
// It will be not needed when homeserver implements auto accept knock requests.
await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click();
await expect(
page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }),
).toBeVisible();
await expect(page.getByText("Alice joined the room")).toBeVisible();
// bot kicks Alice
await bot.kick(room.roomId, user.userId);
await roomPreviewBar.getByRole("button", { name: "Re-join" }).click();
await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join Cybersecurity?" })).toBeVisible();
await roomPreviewBar.getByRole("button", { name: "Request access" }).click();
// bot waits for knock request from Alice
await waitForRoom(page, bot, room.roomId, (room) => {
const events = room.getLiveTimeline().getEvents();
return events.some(
(e) =>
e.getType() === "m.room.member" &&
e.getContent()?.membership === "knock" &&
e.getContent()?.displayname === "Alice",
);
});
// bot invites Alice
await bot.inviteUser(room.roomId, user.userId);
// Alice have to accept invitation in order to join the room.
// It will be not needed when homeserver implements auto accept knock requests.
await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click();
await expect(page.getByText("Alice was invited, joined, was removed, was invited, and joined")).toBeVisible();
});
test("should knock into the room then knock is approved and user joins the room then user is banned/unbanned and joins again", async ({
page,
app,
user,
bot,
room,
}) => {
await app.viewRoomById(room.roomId);
const roomPreviewBar = page.locator(".mx_RoomPreviewBar");
await roomPreviewBar.getByRole("button", { name: "Join the discussion" }).click();
await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join?" })).toBeVisible();
await expect(roomPreviewBar.getByRole("textbox")).toBeVisible();
await roomPreviewBar.getByRole("button", { name: "Request access" }).click();
await expect(roomPreviewBar.getByRole("heading", { name: "Request to join sent" })).toBeVisible();
// Knocked room should appear in Rooms
await expect(
page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }),
).toBeVisible();
// bot waits for knock request from Alice
await waitForRoom(page, bot, room.roomId, (room) => {
const events = room.getLiveTimeline().getEvents();
return events.some(
(e) =>
e.getType() === "m.room.member" &&
e.getContent()?.membership === "knock" &&
e.getContent()?.displayname === "Alice",
);
});
// bot invites Alice
await bot.inviteUser(room.roomId, user.userId);
await expect(
page.getByRole("group", { name: "Invites" }).getByRole("treeitem", { name: "Cybersecurity" }),
).toBeVisible();
// Alice have to accept invitation in order to join the room.
// It will be not needed when homeserver implements auto accept knock requests.
await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click();
await expect(
page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }),
).toBeVisible();
await expect(page.getByText("Alice joined the room")).toBeVisible();
// bot bans Alice
await bot.ban(room.roomId, user.userId);
await expect(
page.locator(".mx_RoomPreviewBar").getByText("You were banned from Cybersecurity by Bob"),
).toBeVisible();
// bot unbans Alice
await bot.unban(room.roomId, user.userId);
await roomPreviewBar.getByRole("button", { name: "Re-join" }).click();
await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join Cybersecurity?" })).toBeVisible();
await roomPreviewBar.getByRole("button", { name: "Request access" }).click();
// bot waits for knock request from Alice
await waitForRoom(page, bot, room.roomId, (room) => {
const events = room.getLiveTimeline().getEvents();
return events.some(
(e) =>
e.getType() === "m.room.member" &&
e.getContent()?.membership === "knock" &&
e.getContent()?.displayname === "Alice",
);
});
// bot invites Alice
await bot.inviteUser(room.roomId, user.userId);
// Alice have to accept invitation in order to join the room.
// It will be not needed when homeserver implements auto accept knock requests.
await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click();
await expect(
page.getByText("Alice was invited, joined, was banned, was unbanned, was invited, and joined"),
).toBeVisible();
});
test("should knock into the room and knock is cancelled by user himself", async ({ page, app, bot, room }) => {
await app.viewRoomById(room.roomId);
const roomPreviewBar = page.locator(".mx_RoomPreviewBar");
await roomPreviewBar.getByRole("button", { name: "Join the discussion" }).click();
await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join?" })).toBeVisible();
await expect(roomPreviewBar.getByRole("textbox")).toBeVisible();
await roomPreviewBar.getByRole("button", { name: "Request access" }).click();
await expect(roomPreviewBar.getByRole("heading", { name: "Request to join sent" })).toBeVisible();
// Knocked room should appear in Rooms
page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" });
await roomPreviewBar.getByRole("button", { name: "Cancel request" }).click();
await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join Cybersecurity?" })).toBeVisible();
await expect(roomPreviewBar.getByRole("button", { name: "Request access" })).toBeVisible();
await expect(
page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }),
).not.toBeVisible();
});
test("should knock into the room then knock is cancelled by another user and room is forgotten", async ({
page,
app,
user,
bot,
room,
}) => {
await app.viewRoomById(room.roomId);
const roomPreviewBar = page.locator(".mx_RoomPreviewBar");
await roomPreviewBar.getByRole("button", { name: "Join the discussion" }).click();
await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join?" })).toBeVisible();
await expect(roomPreviewBar.getByRole("textbox")).toBeVisible();
await roomPreviewBar.getByRole("button", { name: "Request access" }).click();
await expect(roomPreviewBar.getByRole("heading", { name: "Request to join sent" })).toBeVisible();
// Knocked room should appear in Rooms
await expect(
page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity" }),
).toBeVisible();
// bot waits for knock request from Alice
await waitForRoom(page, bot, room.roomId, (room) => {
const events = room.getLiveTimeline().getEvents();
return events.some(
(e) =>
e.getType() === "m.room.member" &&
e.getContent()?.membership === "knock" &&
e.getContent()?.displayname === "Alice",
);
});
// bot kicks Alice
await bot.kick(room.roomId, user.userId);
// Room should stay in Rooms and have red badge when knock is denied
await expect(
page.getByRole("group", { name: "Rooms" }).getByRole("treeitem", { name: "Cybersecurity", exact: true }),
).not.toBeVisible();
await expect(
page
.getByRole("group", { name: "Rooms" })
.getByRole("treeitem", { name: "Cybersecurity 1 unread mention." }),
).toBeVisible();
await expect(roomPreviewBar.getByRole("heading", { name: "You have been denied access" })).toBeVisible();
await roomPreviewBar.getByRole("button", { name: "Forget this room" }).click();
// Room should disappear from the list completely when forgotten
// Should be enabled when issue is fixed: https://github.com/vector-im/element-web/issues/26195
// await expect(page.getByRole("treeitem", { name: /Cybersecurity/ })).not.toBeVisible();
});
test("should knock into the public knock room via spotlight", async ({ page, app, bot, room }) => {
await bot.setRoomDirectoryVisibility(room.roomId, "public" as Visibility);
const spotlightDialog = await app.openSpotlight();
await spotlightDialog.filter(Filter.PublicRooms);
await expect(spotlightDialog.results.nth(0)).toContainText("Cybersecurity");
await spotlightDialog.results.nth(0).click();
const roomPreviewBar = page.locator(".mx_RoomPreviewBar");
await expect(roomPreviewBar.getByRole("heading", { name: "Ask to join?" })).toBeVisible();
await expect(roomPreviewBar.getByRole("textbox")).toBeVisible();
await roomPreviewBar.getByRole("button", { name: "Request access" }).click();
await expect(roomPreviewBar.getByRole("heading", { name: "Request to join sent" })).toBeVisible();
});
});

View file

@ -0,0 +1,110 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Mikhail Aheichyk
Copyright 2023 Nordeck IT + Consulting GmbH.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { waitForRoom } from "../utils";
test.describe("Manage Knocks", () => {
test.use({
displayName: "Alice",
labsFlags: ["feature_ask_to_join"],
botCreateOpts: {
displayName: "Bob",
},
room: async ({ app, user }, use) => {
const roomId = await app.client.createRoom({
name: "Cybersecurity",
initial_state: [
{
type: "m.room.join_rules",
content: {
join_rule: "knock",
},
state_key: "",
},
],
});
await app.viewRoomById(roomId);
await use({ roomId });
},
});
test("should approve knock using bar", async ({ page, bot, room }) => {
await bot.knockRoom(room.roomId);
const roomKnocksBar = page.locator(".mx_RoomKnocksBar");
await expect(roomKnocksBar.getByRole("heading", { name: "Asking to join" })).toBeVisible();
await expect(roomKnocksBar.getByText(/^Bob/)).toBeVisible();
await roomKnocksBar.getByRole("button", { name: "Approve" }).click();
await expect(roomKnocksBar).not.toBeVisible();
await expect(page.getByText("Alice invited Bob")).toBeVisible();
});
test("should deny knock using bar", async ({ page, app, bot, room }) => {
bot.knockRoom(room.roomId);
const roomKnocksBar = page.locator(".mx_RoomKnocksBar");
await expect(roomKnocksBar.getByRole("heading", { name: "Asking to join" })).toBeVisible();
await expect(roomKnocksBar.getByText(/^Bob/)).toBeVisible();
await roomKnocksBar.getByRole("button", { name: "Deny" }).click();
await expect(roomKnocksBar).not.toBeVisible();
// Should receive Bob's "m.room.member" with "leave" membership when access is denied
await waitForRoom(page, app.client, room.roomId, (room) => {
const events = room.getLiveTimeline().getEvents();
return events.some(
(e) =>
e.getType() === "m.room.member" &&
e.getContent()?.membership === "leave" &&
e.getContent()?.displayname === "Bob",
);
});
});
test("should approve knock using people tab", async ({ page, app, bot, room }) => {
await bot.knockRoom(room.roomId, { reason: "Hello, can I join?" });
await app.settings.openRoomSettings("People");
const settingsGroup = page.getByRole("group", { name: "Asking to join" });
await expect(settingsGroup.getByText(/^Bob/)).toBeVisible();
await expect(settingsGroup.getByText("Hello, can I join?")).toBeVisible();
await settingsGroup.getByRole("button", { name: "Approve" }).click();
await expect(settingsGroup.getByText(/^Bob/)).not.toBeVisible();
await expect(page.getByText("Alice invited Bob")).toBeVisible();
});
test("should deny knock using people tab", async ({ page, app, bot, room }) => {
await bot.knockRoom(room.roomId, { reason: "Hello, can I join?" });
await app.settings.openRoomSettings("People");
const settingsGroup = page.getByRole("group", { name: "Asking to join" });
await expect(settingsGroup.getByText(/^Bob/)).toBeVisible();
await expect(settingsGroup.getByText("Hello, can I join?")).toBeVisible();
await settingsGroup.getByRole("button", { name: "Deny" }).click();
await expect(settingsGroup.getByText(/^Bob/)).not.toBeVisible();
// Should receive Bob's "m.room.member" with "leave" membership when access is denied
await waitForRoom(page, app.client, room.roomId, (room) => {
const events = room.getLiveTimeline().getEvents();
return events.some(
(e) =>
e.getType() === "m.room.member" &&
e.getContent()?.membership === "leave" &&
e.getContent()?.displayname === "Bob",
);
});
});
});

View file

@ -0,0 +1,130 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Bot } from "../../pages/bot";
import type { Locator, Page } from "@playwright/test";
import type { ElementAppPage } from "../../pages/ElementAppPage";
import { test, expect } from "../../element-web-test";
test.describe("Lazy Loading", () => {
const charlies: Bot[] = [];
test.use({
displayName: "Alice",
botCreateOpts: { displayName: "Bob" },
});
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests
});
});
test.beforeEach(async ({ page, homeserver, user, bot }) => {
for (let i = 1; i <= 10; i++) {
const displayName = `Charly #${i}`;
const bot = new Bot(page, homeserver, { displayName, startClient: false, autoAcceptInvites: false });
charlies.push(bot);
}
});
const name = "Lazy Loading Test";
const alias = "#lltest:localhost";
const charlyMsg1 = "hi bob!";
const charlyMsg2 = "how's it going??";
let roomId: string;
async function setupRoomWithBobAliceAndCharlies(page: Page, app: ElementAppPage, bob: Bot, charlies: Bot[]) {
const visibility = await page.evaluate(() => (window as any).matrixcs.Visibility.Public);
roomId = await bob.createRoom({
name,
room_alias_name: "lltest",
visibility,
});
await Promise.all(charlies.map((bot) => bot.joinRoom(alias)));
for (const charly of charlies) {
await charly.sendMessage(roomId, charlyMsg1);
}
for (const charly of charlies) {
await charly.sendMessage(roomId, charlyMsg2);
}
for (let i = 20; i >= 1; --i) {
await bob.sendMessage(roomId, `I will only say this ${i} time(s)!`);
}
await app.client.joinRoom(alias);
await app.viewRoomByName(name);
}
async function checkPaginatedDisplayNames(app: ElementAppPage, charlies: Bot[]) {
await app.timeline.scrollToTop();
for (const charly of charlies) {
await expect(await app.timeline.findEventTile(charly.credentials.displayName, charlyMsg1)).toBeAttached();
await expect(await app.timeline.findEventTile(charly.credentials.displayName, charlyMsg2)).toBeAttached();
}
}
async function openMemberlist(app: ElementAppPage): Promise<void> {
await app.toggleRoomInfoPanel();
const { page } = app;
await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click();
}
function getMemberInMemberlist(page: Page, name: string): Locator {
return page.locator(".mx_MemberList .mx_EntityTile_name").filter({ hasText: name });
}
async function checkMemberList(page: Page, charlies: Bot[]) {
await expect(getMemberInMemberlist(page, "Alice")).toBeAttached();
await expect(getMemberInMemberlist(page, "Bob")).toBeAttached();
for (const charly of charlies) {
await expect(getMemberInMemberlist(page, charly.credentials.displayName)).toBeAttached();
}
}
async function checkMemberListLacksCharlies(page: Page, charlies: Bot[]) {
for (const charly of charlies) {
await expect(getMemberInMemberlist(page, charly.credentials.displayName)).not.toBeAttached();
}
}
async function joinCharliesWhileAliceIsOffline(page: Page, app: ElementAppPage, charlies: Bot[]) {
await app.client.network.goOffline();
for (const charly of charlies) {
await charly.joinRoom(alias);
}
for (let i = 20; i >= 1; --i) {
await charlies[0].sendMessage(roomId, "where is charly?");
}
await app.client.network.goOnline();
await app.client.waitForNextSync();
}
test("should handle lazy loading properly even when offline", async ({ page, app, bot }) => {
test.slow();
const charly1to5 = charlies.slice(0, 5);
const charly6to10 = charlies.slice(5);
// Set up room with alice, bob & charlies 1-5
await setupRoomWithBobAliceAndCharlies(page, app, bot, charly1to5);
// Alice should see 2 messages from every charly with the correct display name
await checkPaginatedDisplayNames(app, charly1to5);
await openMemberlist(app);
await checkMemberList(page, charly1to5);
await joinCharliesWhileAliceIsOffline(page, app, charly6to10);
await checkMemberList(page, charly6to10);
for (const charly of charlies) {
await charly.evaluate((client, roomId) => client.leave(roomId), roomId);
}
await checkMemberListLacksCharlies(page, charlies);
});
});

View file

@ -0,0 +1,23 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Suguru Hirahara
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("LeftPanel", () => {
test.use({
displayName: "Hanako",
});
test("should render the Rooms list", async ({ page, app, user }) => {
// create rooms and check room names are correct
for (const name of ["Apple", "Pineapple", "Orange"]) {
await app.client.createRoom({ name });
await expect(page.getByRole("treeitem", { name })).toBeVisible();
}
});
});

View file

@ -0,0 +1,59 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Locator, Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
test.describe("Location sharing", () => {
const selectLocationShareTypeOption = (page: Page, shareType: string): Locator => {
return page.getByTestId(`share-location-option-${shareType}`);
};
const submitShareLocation = (page: Page): Promise<void> => {
return page.getByRole("button", { name: "Share location" }).click();
};
test.use({
displayName: "Tom",
});
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("mx_lhs_size", "0");
});
});
test("sends and displays pin drop location message successfully", async ({ page, user, app }) => {
const roomId = await app.client.createRoom({});
await page.goto(`/#/room/${roomId}`);
const composerOptions = await app.openMessageComposerOptions();
await composerOptions.getByRole("menuitem", { name: "Location", exact: true }).click();
await selectLocationShareTypeOption(page, "Pin").click();
await page.locator("#mx_LocationPicker_map").click();
await submitShareLocation(page);
await page.locator(".mx_RoomView_body .mx_EventTile .mx_MLocationBody").click({
position: {
x: 225,
y: 150,
},
});
// clicking location tile opens maximised map
await expect(page.getByRole("dialog")).toBeVisible();
await app.closeDialog();
await expect(page.locator(".mx_Marker")).toBeVisible();
});
});

View file

@ -0,0 +1,45 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Consent", () => {
test.use({
startHomeserverOpts: "consent",
displayName: "Bob",
});
test("should prompt the user to consent to terms when server deems it necessary", async ({
context,
page,
user,
app,
}) => {
// Attempt to create a room using the js-sdk which should return an error with `M_CONSENT_NOT_GIVEN`
await app.client.createRoom({}).catch(() => {});
const newPagePromise = context.waitForEvent("page");
const dialog = page.locator(".mx_QuestionDialog");
// Accept terms & conditions
await expect(dialog.getByRole("heading", { name: "Terms and Conditions" })).toBeVisible();
await page.getByRole("button", { name: "Review terms and conditions" }).click();
const newPage = await newPagePromise;
await newPage.locator('[type="submit"]').click();
await expect(newPage.getByText("Danke schoen")).toBeVisible();
// go back to the app
await page.goto("/");
// wait for the app to re-load
await expect(page.locator(".mx_MatrixChat")).toBeVisible();
// attempt to perform the same action again and expect it to not fail
await app.client.createRoom({ name: "Test Room" });
await expect(page.getByText("Test Room")).toBeVisible();
});
});

View file

@ -0,0 +1,288 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Page } from "playwright-core";
import { expect, test } from "../../element-web-test";
import { doTokenRegistration } from "./utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { selectHomeserver } from "../utils";
import { Credentials, HomeserverInstance } from "../../plugins/homeserver";
const username = "user1234";
const password = "p4s5W0rD";
// Pre-generated dummy signing keys to create an account that has signing keys set.
// Note the signatures are specific to the username and must be valid or the HS will reject the keys.
const DEVICE_SIGNING_KEYS_BODY = {
master_key: {
keys: {
"ed25519:6qCouJsi2j7DzOmpxPTBALpvDTqa8p2mjrQR2P8wEbg": "6qCouJsi2j7DzOmpxPTBALpvDTqa8p2mjrQR2P8wEbg",
},
signatures: {
"@user1234:localhost": {
"ed25519:6qCouJsi2j7DzOmpxPTBALpvDTqa8p2mjrQR2P8wEbg":
"mvwqsYiGa2gPH6ueJsiJnceHMrZhf1pqIMGxkvKisN3ucz8sU7LwyzndbYaLkUKEDx1JuOKFfZ9Mb3mqc7PMBQ",
"ed25519:SRHVWTNVBH":
"HVGmVIzsJe3d+Un/6S9tXPsU7YA8HjZPdxogVzdjEFIU8OjLyElccvjupow0rVWgkEqU8sO21LIHw9cWRZEmDw",
},
},
usage: ["master"],
user_id: "@user1234:localhost",
},
self_signing_key: {
keys: {
"ed25519:eqzRly4S1GvTA36v48hOKokHMtYBLm02zXRgPHue5/8": "eqzRly4S1GvTA36v48hOKokHMtYBLm02zXRgPHue5/8",
},
signatures: {
"@user1234:localhost": {
"ed25519:6qCouJsi2j7DzOmpxPTBALpvDTqa8p2mjrQR2P8wEbg":
"M2rt5xs+23egbVUwUcZuU7pMpn0chBNC5rpdyZGayfU3FDlx1DbopbakIcl5v4uOSGMbqUotyzkE6CchB+dgDw",
},
},
usage: ["self_signing"],
user_id: "@user1234:localhost",
},
user_signing_key: {
keys: {
"ed25519:h6C7sonjKSSa/VMvmpmFnwMA02H2rKIMSYZ2ddwgJn4": "h6C7sonjKSSa/VMvmpmFnwMA02H2rKIMSYZ2ddwgJn4",
},
signatures: {
"@user1234:localhost": {
"ed25519:6qCouJsi2j7DzOmpxPTBALpvDTqa8p2mjrQR2P8wEbg":
"5ZMJ7SG2qr76vU2nITKap88AxLZ/RZQmF/mBcAcVZ9Bknvos3WQp8qN9jKuiqOHCq/XpPORA6XBmiDIyPqTFAA",
},
},
usage: ["user_signing"],
user_id: "@user1234:localhost",
},
auth: {
type: "m.login.password",
identifier: { type: "m.id.user", user: "@user1234:localhost" },
password: password,
},
};
async function login(page: Page, homeserver: HomeserverInstance) {
await page.getByRole("link", { name: "Sign in" }).click();
await selectHomeserver(page, homeserver.config.baseUrl);
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByPlaceholder("Password").fill(password);
await page.getByRole("button", { name: "Sign in" }).click();
}
test.describe("Login", () => {
test.describe("Password login", () => {
test.use({ startHomeserverOpts: "consent" });
let creds: Credentials;
test.beforeEach(async ({ homeserver }) => {
creds = await homeserver.registerUser(username, password);
});
test("Loads the welcome page by default; then logs in with an existing account and lands on the home screen", async ({
page,
homeserver,
checkA11y,
}) => {
await page.goto("/");
// Should give us the welcome page initially
await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible();
// Start the login process
await page.getByRole("link", { name: "Sign in" }).click();
// first pick the homeserver, as otherwise the user picker won't be visible
await selectHomeserver(page, homeserver.config.baseUrl);
await page.getByRole("button", { name: "Edit" }).click();
// select the default server again
await page.locator(".mx_StyledRadioButton").first().click();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page.locator(".mx_ServerPickerDialog")).toHaveCount(0);
await expect(page.locator(".mx_Spinner")).toHaveCount(0);
// name of default server
await expect(page.locator(".mx_ServerPicker_server")).toHaveText("server.invalid");
// switch back to the custom homeserver
await selectHomeserver(page, homeserver.config.baseUrl);
await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible();
// Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688
// cy.percySnapshot("Login");
await checkA11y();
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByPlaceholder("Password").fill(password);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL(/\/#\/home$/);
});
test("Follows the original link after login", async ({ page, homeserver }) => {
await page.goto("/#/room/!room:id"); // should redirect to the welcome page
await login(page, homeserver);
await expect(page).toHaveURL(/\/#\/room\/!room:id$/);
await expect(page.getByRole("button", { name: "Join the discussion" })).toBeVisible();
});
test.describe("verification after login", () => {
test("Shows verification prompt after login if signing keys are set up, skippable by default", async ({
page,
homeserver,
request,
}) => {
const res = await request.post(
`${homeserver.config.baseUrl}/_matrix/client/v3/keys/device_signing/upload`,
{ headers: { Authorization: `Bearer ${creds.accessToken}` }, data: DEVICE_SIGNING_KEYS_BODY },
);
if (res.status() / 100 !== 2) {
console.log("Uploading dummy keys failed", await res.json());
}
expect(res.status() / 100).toEqual(2);
await page.goto("/");
await login(page, homeserver);
await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible();
await expect(page.getByRole("button", { name: "Skip verification for now" })).toBeVisible();
});
test.describe("with force_verification off", () => {
test.use({
config: {
force_verification: false,
},
});
test("Shows skippable verification prompt after login if signing keys are set up", async ({
page,
homeserver,
request,
}) => {
const res = await request.post(
`${homeserver.config.baseUrl}/_matrix/client/v3/keys/device_signing/upload`,
{ headers: { Authorization: `Bearer ${creds.accessToken}` }, data: DEVICE_SIGNING_KEYS_BODY },
);
if (res.status() / 100 !== 2) {
console.log("Uploading dummy keys failed", await res.json());
}
expect(res.status() / 100).toEqual(2);
await page.goto("/");
await login(page, homeserver);
await expect(page.getByRole("heading", { name: "Verify this device", level: 1 })).toBeVisible();
await expect(page.getByRole("button", { name: "Skip verification for now" })).toBeVisible();
});
});
test.describe("with force_verification on", () => {
test.use({
config: {
force_verification: true,
},
});
test("Shows unskippable verification prompt after login if signing keys are set up", async ({
page,
homeserver,
request,
}) => {
console.log(`uid ${creds.userId} body`, DEVICE_SIGNING_KEYS_BODY);
const res = await request.post(
`${homeserver.config.baseUrl}/_matrix/client/v3/keys/device_signing/upload`,
{ headers: { Authorization: `Bearer ${creds.accessToken}` }, data: DEVICE_SIGNING_KEYS_BODY },
);
if (res.status() / 100 !== 2) {
console.log("Uploading dummy keys failed", await res.json());
}
expect(res.status() / 100).toEqual(2);
await page.goto("/");
await login(page, homeserver);
const h1 = await page.getByRole("heading", { name: "Verify this device", level: 1 });
await expect(h1).toBeVisible();
expect(h1.locator(".mx_CompleteSecurity_skip")).not.toBeVisible();
});
});
});
});
// tests for old-style SSO login, in which we exchange tokens with Synapse, and Synapse talks to an auth server
test.describe("SSO login", () => {
test.skip(isDendrite, "does not yet support SSO");
test.use({
startHomeserverOpts: ({ oAuthServer }, use) =>
use({
template: "default",
oAuthServerPort: oAuthServer.port,
}),
});
test("logs in with SSO and lands on the home screen", async ({ page, homeserver }) => {
// If this test fails with a screen showing "Timeout connecting to remote server", it is most likely due to
// your firewall settings: Synapse is unable to reach the OIDC server.
//
// If you are using ufw, try something like:
// sudo ufw allow in on docker0
//
await doTokenRegistration(page, homeserver);
});
});
test.describe("logout", () => {
test.use({ startHomeserverOpts: "consent" });
test("should go to login page on logout", async ({ page, user }) => {
await page.getByRole("button", { name: "User menu" }).click();
await expect(page.getByText(user.displayName, { exact: true })).toBeVisible();
// Allow the outstanding requests queue to settle before logging out
await page.waitForTimeout(2000);
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
await expect(page).toHaveURL(/\/#\/login$/);
});
});
test.describe("logout with logout_redirect_url", () => {
test.use({
startHomeserverOpts: "consent",
config: {
// We redirect to decoder-ring because it's a predictable page that isn't Element itself.
// We could use example.org, matrix.org, or something else, however this puts dependency of external
// infrastructure on our tests. In the same vein, we don't really want to figure out how to ship a
// `test-landing.html` page when running with an uncontrolled Element (via `yarn start`).
// Using the decoder-ring is just as fine, and we can search for strategic names.
logout_redirect_url: "/decoder-ring/",
},
});
test("should respect logout_redirect_url", async ({ page, user }) => {
await page.getByRole("button", { name: "User menu" }).click();
await expect(page.getByText(user.displayName, { exact: true })).toBeVisible();
// give a change for the outstanding requests queue to settle before logging out
await page.waitForTimeout(2000);
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
await expect(page).toHaveURL(/\/decoder-ring\/$/);
});
});
});

View file

@ -0,0 +1,46 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { logIntoElement } from "../crypto/utils";
test.describe("Overwrite login action", () => {
// This seems terminally flakey: https://github.com/element-hq/element-web/issues/27363
// I tried verious things to try & deflake it, to no avail: https://github.com/matrix-org/matrix-react-sdk/pull/12506
test.skip("Try replace existing login with new one", async ({ page, app, credentials, homeserver }) => {
await logIntoElement(page, homeserver, credentials);
const userMenu = await app.openUserMenu();
await expect(userMenu.getByText(credentials.userId)).toBeVisible();
const bobRegister = await homeserver.registerUser("BobOverwrite", "p@ssword1!", "BOB");
// just assert that it's a different user
expect(credentials.userId).not.toBe(bobRegister.userId);
const clientCredentials /* IMatrixClientCreds */ = {
homeserverUrl: homeserver.config.baseUrl,
...bobRegister,
};
// Trigger the overwrite login action
await app.client.evaluate(async (cli, clientCredentials) => {
// @ts-ignore - raw access to the dispatcher to simulate the action
window.mxDispatcher.dispatch(
{
action: "overwrite_login",
credentials: clientCredentials,
},
true,
);
}, clientCredentials);
// It should be now another user!!
await expect(page.getByText("Welcome BOB")).toBeVisible();
});
});

View file

@ -0,0 +1,122 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { doTokenRegistration } from "./utils";
import { Credentials } from "../../plugins/homeserver";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Soft logout", () => {
test.use({
displayName: "Alice",
startHomeserverOpts: ({ oAuthServer }, use) =>
use({
template: "default",
oAuthServerPort: oAuthServer.port,
}),
});
test.describe("with password user", () => {
test("shows the soft-logout page when a request fails, and allows a re-login", async ({ page, user }) => {
await interceptRequestsWithSoftLogout(page, user);
await expect(page.getByText("You're signed out")).toBeVisible();
await page.getByPlaceholder("Password").fill(user.password);
await page.getByPlaceholder("Password").press("Enter");
// back to the welcome page
await expect(page).toHaveURL(/\/#\/home/);
await expect(page.getByRole("heading", { name: `Welcome ${user.userId}`, exact: true })).toBeVisible();
});
test("still shows the soft-logout page when the page is reloaded after a soft-logout", async ({
page,
user,
}) => {
await interceptRequestsWithSoftLogout(page, user);
await expect(page.getByText("You're signed out")).toBeVisible();
await page.reload();
await expect(page.getByText("You're signed out")).toBeVisible();
});
});
test.describe("with SSO user", () => {
test.skip(isDendrite, "does not yet support SSO");
test.use({
user: async ({ page, homeserver }, use) => {
const user = await doTokenRegistration(page, homeserver);
// Eventually, we should end up at the home screen.
await expect(page).toHaveURL(/\/#\/home$/);
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
await use(user);
},
});
test("shows the soft-logout page when a request fails, and allows a re-login", async ({ page, user }) => {
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
await interceptRequestsWithSoftLogout(page, user);
await expect(page.getByText("You're signed out")).toBeVisible();
await page.getByRole("button", { name: "Continue with OAuth test" }).click();
// click the submit button
await page.getByRole("button", { name: "Submit" }).click();
// Synapse prompts us to grant permission to Element
await expect(page.getByRole("heading", { name: "Continue to your account" })).toBeVisible();
await page.getByRole("link", { name: "Continue" }).click();
// back to the welcome page
await expect(page).toHaveURL(/\/#\/home$/);
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
});
});
});
/**
* Intercept calls to /sync and have them fail with a soft-logout
*
* Any further requests to /sync with the same access token are blocked.
*/
async function interceptRequestsWithSoftLogout(page: Page, user: Credentials): Promise<void> {
await page.route("**/_matrix/client/*/sync*", async (route, req) => {
const accessToken = await req.headerValue("Authorization");
// now, if the access token on this request matches the expired one, block it
if (accessToken === `Bearer ${user.accessToken}`) {
console.log("Intercepting request with soft-logged-out access token");
await route.fulfill({
status: 401,
json: {
errcode: "M_UNKNOWN_TOKEN",
error: "Soft logout",
soft_logout: true,
},
});
return;
}
// otherwise, pass through as normal
await route.continue();
});
const promise = page.waitForResponse((resp) => resp.url().includes("/sync") && resp.status() === 401);
// do something to make the active /sync return: create a new room
await page.evaluate(() => {
// don't wait for this to complete: it probably won't, because of the broken sync
window.mxMatrixClientPeg.get().createRoom({});
});
await promise;
}

View file

@ -0,0 +1,60 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Page, expect } from "@playwright/test";
import { Credentials, HomeserverInstance } from "../../plugins/homeserver";
/** Visit the login page, choose to log in with "OAuth test", register a new account, and redirect back to Element
*/
export async function doTokenRegistration(
page: Page,
homeserver: HomeserverInstance,
): Promise<Credentials & { displayName: string }> {
await page.goto("/#/login");
await page.getByRole("button", { name: "Edit" }).click();
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl);
await page.getByRole("button", { name: "Continue" }).click();
// wait for the dialog to go away
await expect(page.locator(".mx_ServerPickerDialog")).toHaveCount(0);
// click on "Continue with OAuth test"
await page.getByRole("button", { name: "Continue with OAuth test" }).click();
// wait for the Test OAuth Page to load
await expect(page.getByText("Test OAuth page")).toBeVisible();
// click the submit button
await page.getByRole("button", { name: "Submit" }).click();
// Synapse prompts us to pick a user ID
await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible();
await page.getByRole("textbox", { name: "Username (required)" }).fill("alice");
// wait for username validation to start, and complete
await expect(page.locator("#field-username-output")).toHaveText("");
await page.getByRole("button", { name: "Continue" }).click();
// Synapse prompts us to grant permission to Element
page.getByRole("heading", { name: "Continue to your account" });
await page.getByRole("link", { name: "Continue" }).click();
// Eventually, we should end up at the home screen.
await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 });
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
return page.evaluate(() => ({
accessToken: window.mxMatrixClientPeg.get().getAccessToken(),
userId: window.mxMatrixClientPeg.get().getUserId(),
deviceId: window.mxMatrixClientPeg.get().getDeviceId(),
homeServer: window.mxMatrixClientPeg.get().getHomeserverUrl(),
password: null,
displayName: "Alice",
}));
}

View file

@ -0,0 +1,178 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* See readme.md for tips on writing these tests. */
import { Locator, Page } from "playwright-core";
import { test, expect } from "../../element-web-test";
async function sendMessage(page: Page, message: string): Promise<Locator> {
await page.getByRole("textbox", { name: "Send a message…" }).fill(message);
await page.getByRole("button", { name: "Send message" }).click();
const msgTile = page.locator(".mx_EventTile_last");
await msgTile.locator(".mx_EventTile_receiptSent").waitFor();
return msgTile;
}
async function sendMultilineMessages(page: Page, messages: string[]) {
await page.getByRole("textbox", { name: "Send a message…" }).focus();
for (let i = 0; i < messages.length; i++) {
await page.keyboard.type(messages[i]);
if (i < messages.length - 1) await page.keyboard.press("Shift+Enter");
}
await page.getByRole("button", { name: "Send message" }).click();
const msgTile = page.locator(".mx_EventTile_last");
await msgTile.locator(".mx_EventTile_receiptSent").waitFor();
return msgTile;
}
async function replyMessage(page: Page, message: Locator, replyMessage: string): Promise<Locator> {
const line = message.locator(".mx_EventTile_line");
await line.hover();
await line.getByRole("button", { name: "Reply", exact: true }).click();
await page.getByRole("textbox", { name: "Send a reply…" }).fill(replyMessage);
await page.getByRole("button", { name: "Send message" }).click();
const msgTile = page.locator(".mx_EventTile_last");
await msgTile.locator(".mx_EventTile_receiptSent").waitFor();
return msgTile;
}
async function editMessage(page: Page, message: Locator, newMsg: string): Promise<void> {
const line = message.locator(".mx_EventTile_line");
await line.hover();
await line.getByRole("button", { name: "Edit" }).click();
const editComposer = page.getByRole("textbox", { name: "Edit message" });
await page.getByLabel("User menu").hover(); // Just to un-hover the message line
await editComposer.fill(newMsg);
await editComposer.press("Enter");
}
test.describe("Message rendering", () => {
[
{ direction: "ltr", displayName: "Quentin" },
{ direction: "rtl", displayName: "كوينتين" },
].forEach(({ direction, displayName }) => {
test.describe(`with ${direction} display name`, () => {
test.use({
displayName,
room: async ({ user, app }, use) => {
const roomId = await app.client.createRoom({ name: "Test room" });
await use({ roomId });
},
});
test("should render a basic LTR text message", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "Hello, world!");
await expect(msgTile).toMatchScreenshot(`basic-message-ltr-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
test("should render an LTR emote", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "/me lays an egg");
await expect(msgTile).toMatchScreenshot(`emote-ltr-${direction}displayname.png`);
});
test("should render an LTR rich text emote", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "/me lays a *free range* egg");
await expect(msgTile).toMatchScreenshot(`emote-rich-ltr-${direction}displayname.png`);
});
test("should render an edited LTR message", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "Hello, world!");
await editMessage(page, msgTile, "Hello, universe!");
await expect(msgTile).toMatchScreenshot(`edited-message-ltr-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
test("should render a reply of a LTR message", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMultilineMessages(page, [
"Fist line",
"Second line",
"Third line",
"Fourth line",
]);
await replyMessage(page, msgTile, "response to multiline message");
await expect(msgTile).toMatchScreenshot(`reply-message-ltr-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
test("should render a basic RTL text message", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "مرحبا بالعالم!");
await expect(msgTile).toMatchScreenshot(`basic-message-rtl-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
test("should render an RTL emote", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "/me يضع بيضة");
await expect(msgTile).toMatchScreenshot(`emote-rtl-${direction}displayname.png`);
});
test("should render a richtext RTL emote", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "/me أضع بيضة *حرة النطاق*");
await expect(msgTile).toMatchScreenshot(`emote-rich-rtl-${direction}displayname.png`);
});
test("should render an edited RTL message", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMessage(page, "مرحبا بالعالم!");
await editMessage(page, msgTile, "مرحبا بالكون!");
await expect(msgTile).toMatchScreenshot(`edited-message-rtl-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
test("should render a reply of a RTL message", async ({ page, user, app, room }) => {
await page.goto(`#/room/${room.roomId}`);
const msgTile = await sendMultilineMessages(page, [
"مرحبا بالعالم!",
"مرحبا بالعالم!",
"مرحبا بالعالم!",
"مرحبا بالعالم!",
]);
await replyMessage(page, msgTile, "مرحبا بالعالم!");
await expect(msgTile).toMatchScreenshot(`reply-message-trl-${direction}displayname.png`, {
mask: [page.locator(".mx_MessageTimestamp")],
});
});
});
});
});

View file

@ -0,0 +1,96 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { API, Messages } from "mailhog";
import { Page } from "@playwright/test";
import { test as base, expect } from "../../element-web-test";
import { MatrixAuthenticationService } from "../../plugins/matrix-authentication-service";
import { StartHomeserverOpts } from "../../plugins/homeserver";
export const test = base.extend<{
masPrepare: MatrixAuthenticationService;
mas: MatrixAuthenticationService;
}>({
// There's a bit of a chicken and egg problem between MAS & Synapse where they each need to know how to reach each other
// so spinning up a MAS is split into the prepare & start stage: prepare mas -> homeserver -> start mas to disentangle this.
masPrepare: async ({ context }, use) => {
const mas = new MatrixAuthenticationService(context);
await mas.prepare();
await use(mas);
},
mas: [
async ({ masPrepare: mas, homeserver, mailhog }, use, testInfo) => {
await mas.start(homeserver, mailhog.instance);
await use(mas);
await mas.stop(testInfo);
},
{ auto: true },
],
startHomeserverOpts: async ({ masPrepare }, use) => {
await use({
template: "mas-oidc",
variables: {
MAS_PORT: masPrepare.port,
},
});
},
config: async ({ homeserver, startHomeserverOpts, context }, use) => {
const issuer = `http://localhost:${(startHomeserverOpts as StartHomeserverOpts).variables["MAS_PORT"]}/`;
const wellKnown = {
"m.homeserver": {
base_url: homeserver.config.baseUrl,
},
"org.matrix.msc2965.authentication": {
issuer,
account: `${issuer}account`,
},
};
// Ensure org.matrix.msc2965.authentication is in well-known
await context.route("https://localhost/.well-known/matrix/client", async (route) => {
await route.fulfill({ json: wellKnown });
});
await use({
default_server_config: wellKnown,
});
},
});
export { expect };
export async function registerAccountMas(
page: Page,
mailhog: API,
username: string,
email: string,
password: string,
): Promise<void> {
await expect(page.getByText("Please sign in to continue:")).toBeVisible();
await page.getByRole("link", { name: "Create Account" }).click();
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByRole("textbox", { name: "Email address" }).fill(email);
await page.getByRole("textbox", { name: "Password", exact: true }).fill(password);
await page.getByRole("textbox", { name: "Confirm Password" }).fill(password);
await page.getByRole("button", { name: "Continue" }).click();
let messages: Messages;
await expect(async () => {
messages = await mailhog.messages();
expect(messages.items).toHaveLength(1);
}).toPass();
expect(messages.items[0].to).toEqual(`${username} <${email}>`);
const [code] = messages.items[0].text.match(/(\d{6})/);
await page.getByRole("textbox", { name: "6-digit code" }).fill(code);
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByText("Allow access to your account?")).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
}

View file

@ -0,0 +1,34 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect, registerAccountMas } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("OIDC Aware", () => {
test.skip(isDendrite, "does not yet support MAS");
test.slow(); // trace recording takes a while here
test("can register an account and manage it", async ({ context, page, homeserver, mailhog, app }) => {
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhog.api, "alice", "alice@email.com", "Pa$sW0rD!");
// Eventually, we should end up at the home screen.
await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 });
await expect(page.getByRole("heading", { name: "Welcome alice", exact: true })).toBeVisible();
// Open settings and navigate to account management
await app.settings.openUserSettings("Account");
const newPagePromise = context.waitForEvent("page");
await page.getByRole("button", { name: "Manage account" }).click();
// Assert new tab opened
const newPage = await newPagePromise;
await expect(newPage.getByText("Primary email")).toBeVisible();
});
});

View file

@ -0,0 +1,68 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect, registerAccountMas } from ".";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { ElementAppPage } from "../../pages/ElementAppPage.ts";
test.describe("OIDC Native", () => {
test.skip(isDendrite, "does not yet support MAS");
test.slow(); // trace recording takes a while here
test.use({
labsFlags: ["feature_oidc_native_flow"],
});
test("can register the oauth2 client and an account", async ({ context, page, homeserver, mailhog, mas }) => {
const tokenUri = `http://localhost:${mas.port}/oauth2/token`;
const tokenApiPromise = page.waitForRequest(
(request) => request.url() === tokenUri && request.postDataJSON()["grant_type"] === "authorization_code",
);
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailhog.api, "alice", "alice@email.com", "Pa$sW0rD!");
// Eventually, we should end up at the home screen.
await expect(page).toHaveURL(/\/#\/home$/, { timeout: 10000 });
await expect(page.getByRole("heading", { name: "Welcome alice", exact: true })).toBeVisible();
const tokenApiRequest = await tokenApiPromise;
expect(tokenApiRequest.postDataJSON()["grant_type"]).toBe("authorization_code");
const deviceId = await page.evaluate<string>(() => window.localStorage.mx_device_id);
const app = new ElementAppPage(page);
await app.settings.openUserSettings("Account");
const newPagePromise = context.waitForEvent("page");
await page.getByRole("button", { name: "Manage account" }).click();
await app.settings.closeDialog();
// Assert MAS sees the session as OIDC Native
const newPage = await newPagePromise;
await newPage.getByText("Sessions").click();
await newPage.getByText(deviceId).click();
await expect(newPage.getByText("Element")).toBeVisible();
await expect(newPage.getByText("oauth2_session:")).toBeVisible();
await expect(newPage.getByText("http://localhost:8080/")).toBeVisible();
await newPage.close();
// Assert logging out revokes both tokens
const revokeUri = `http://localhost:${mas.port}/oauth2/revoke`;
const revokeAccessTokenPromise = page.waitForRequest(
(request) => request.url() === revokeUri && request.postDataJSON()["token_type_hint"] === "access_token",
);
const revokeRefreshTokenPromise = page.waitForRequest(
(request) => request.url() === revokeUri && request.postDataJSON()["token_type_hint"] === "refresh_token",
);
const locator = await app.settings.openUserMenu();
await locator.getByRole("menuitem", { name: "Sign out", exact: true }).click();
await revokeAccessTokenPromise;
await revokeRefreshTokenPromise;
});
});

View file

@ -0,0 +1,46 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Ahmad Kadri
Copyright 2023 Nordeck IT + Consulting GmbH.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test as base, expect } from "../../element-web-test";
import { Credentials } from "../../plugins/homeserver";
const test = base.extend<{
user2?: Credentials;
}>({});
test.describe("1:1 chat room", () => {
test.use({
displayName: "Jeff",
user2: async ({ homeserver }, use) => {
const credentials = await homeserver.registerUser("user1234", "p4s5W0rD", "Timmy");
await use(credentials);
},
});
test.beforeEach(async ({ page, user2, user }) => {
await page.goto(`/#/user/${user2.userId}?action=chat`);
});
test("should open new 1:1 chat room after leaving the old one", async ({ page, app, user2 }) => {
// leave 1:1 chat room
await app.toggleRoomInfoPanel();
await page.getByRole("menuitem", { name: "Leave room" }).click();
await page.getByRole("button", { name: "Leave" }).click();
// wait till the room was left
await expect(
page.getByRole("group", { name: "Rooms" }).locator(".mx_RoomTile").getByText(user2.displayName),
).not.toBeVisible();
await page.waitForTimeout(500); // avoid race condition with routing
// open new 1:1 chat room
await page.goto(`/#/user/${user2.userId}?action=chat`);
await expect(page.locator(".mx_RoomHeader_heading").getByText(user2.displayName)).toBeVisible();
});
});

View file

@ -0,0 +1,102 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import type { Locator } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { Bot } from "../../pages/bot";
const room1Name = "Room 1";
const room2Name = "Room 2";
const unknownRoomAlias = "#unknownroom:example.com";
const permalinkPrefix = "https://matrix.to/#/";
const getPill = (locator: Locator, label: string) => {
return locator.locator(".mx_Pill_text", { hasText: new RegExp("^" + label + "$", "g") });
};
test.describe("permalinks", () => {
test.use({
displayName: "Alice",
});
test("shoud render permalinks as expected", async ({ page, app, user, homeserver }) => {
const bob = new Bot(page, homeserver, { displayName: "Bob" });
const charlotte = new Bot(page, homeserver, { displayName: "Charlotte" });
await bob.prepareClient();
await charlotte.prepareClient();
// We don't use a bot for danielle as we want a stable MXID.
const danielleId = "@danielle:localhost";
const room1Id = await app.client.createRoom({ name: room1Name });
const room2Id = await app.client.createRoom({ name: room2Name });
await app.viewRoomByName(room1Name);
await app.client.inviteUser(room1Id, bob.credentials.userId);
await app.client.inviteUser(room2Id, charlotte.credentials.userId);
await app.client.sendMessage(room1Id, "At room mention: @room");
await app.client.sendMessage(room1Id, `Permalink to Room 2: ${permalinkPrefix}${room2Id}`);
await app.client.sendMessage(
room1Id,
`Permalink to an unknown room alias: ${permalinkPrefix}${unknownRoomAlias}`,
);
const event1Response = await bob.sendMessage(room1Id, "Hello");
await app.client.sendMessage(
room1Id,
`Permalink to a message in the same room: ${permalinkPrefix}${room1Id}/${event1Response.event_id}`,
);
const event2Response = await charlotte.sendMessage(room2Id, "Hello");
await app.client.sendMessage(
room1Id,
`Permalink to a message in another room: ${permalinkPrefix}${room2Id}/${event2Response.event_id}`,
);
await app.client.sendMessage(room1Id, `Permalink to an unknown message: ${permalinkPrefix}${room1Id}/$abc123`);
await app.client.sendMessage(
room1Id,
`Permalink to a user in the room: ${permalinkPrefix}${bob.credentials.userId}`,
);
await app.client.sendMessage(
room1Id,
`Permalink to a user in another room: ${permalinkPrefix}${charlotte.credentials.userId}`,
);
await app.client.sendMessage(
room1Id,
`Permalink to a user with whom alice doesn't share a room: ${permalinkPrefix}${danielleId}`,
);
const timeline = page.locator(".mx_RoomView_timeline");
getPill(timeline, "@room");
getPill(timeline, room2Name);
getPill(timeline, unknownRoomAlias);
getPill(timeline, "Message from Bob");
getPill(timeline, `Message in ${room2Name}`);
getPill(timeline, "Message");
getPill(timeline, "Bob");
getPill(timeline, "Charlotte");
// This is the permalink to Danielle's profile. It should only display the MXID
// because the profile is unknown (not sharing any room with Danielle).
getPill(timeline, danielleId);
await expect(timeline).toMatchScreenshot("permalink-rendering.png", {
mask: [
// Exclude timestamps from the snapshot, for consistency.
page.locator(".mx_MessageTimestamp"),
],
});
});
});

View file

@ -0,0 +1,276 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import { Page } from "@playwright/test";
import { test as base, expect } from "../../element-web-test";
import { Client } from "../../pages/client";
import { ElementAppPage } from "../../pages/ElementAppPage";
import { Bot } from "../../pages/bot";
/**
* Set up for pinned message tests.
*/
export const test = base.extend<{
room1Name?: string;
room1: { name: string; roomId: string };
util: Helpers;
}>({
displayName: "Alice",
botCreateOpts: { displayName: "Other User" },
room1Name: "Room 1",
room1: async ({ room1Name: name, app, user, bot }, use) => {
const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] });
await use({ name, roomId });
},
util: async ({ page, app, bot }, use) => {
await use(new Helpers(page, app, bot));
},
});
export class Helpers {
constructor(
private page: Page,
private app: ElementAppPage,
private bot: Bot,
) {}
/**
* Sends messages into given room as a bot
* @param room - the name of the room to send messages into
* @param messages - the list of messages to send, these can be strings or implementations of MessageSpec like `editOf`
*/
async receiveMessages(room: string | { name: string }, messages: string[]) {
await this.sendMessageAsClient(this.bot, room, messages);
}
/**
* Use the supplied client to send messages or perform actions as specified by
* the supplied {@link Message} items.
*/
private async sendMessageAsClient(cli: Client, roomName: string | { name: string }, messages: string[]) {
const room = await this.findRoomByName(typeof roomName === "string" ? roomName : roomName.name);
const roomId = await room.evaluate((room) => room.roomId);
for (const message of messages) {
await cli.sendMessage(roomId, { body: message, msgtype: "m.text" });
// TODO: without this wait, some tests that send lots of messages flake
// from time to time. I (andyb) have done some investigation, but it
// needs more work to figure out. The messages do arrive over sync, but
// they never appear in the timeline, and they never fire a
// Room.timeline event. I think this only happens with events that refer
// to other events (e.g. replies), so it might be caused by the
// referring event arriving before the referred-to event.
await this.page.waitForTimeout(100);
}
}
/**
* Find a room by its name
* @param roomName
* @private
*/
private async findRoomByName(roomName: string) {
return this.app.client.evaluateHandle((cli, roomName) => {
return cli.getRooms().find((r) => r.name === roomName);
}, roomName);
}
/**
* Open the room with the supplied name.
*/
async goTo(room: string | { name: string }) {
await this.app.viewRoomByName(typeof room === "string" ? room : room.name);
}
/**
* Get the timeline tile for the given message
* @param message
*/
getEventTile(message: string) {
return this.page.locator(".mx_EventTile", { hasText: message });
}
/**
* Pin the given message from the quick actions
* @param message
* @param unpin
*/
async pinMessageFromQuickActions(message: string, unpin = false) {
const timelineMessage = this.page.locator(".mx_MTextBody", { hasText: message });
await timelineMessage.hover();
await this.page.getByRole("button", { name: unpin ? "Unpin" : "Pin", exact: true }).click();
}
/**
* Pin the given messages from the quick actions
* @param messages
* @param unpin
*/
async pinMessagesFromQuickActions(messages: string[], unpin = false) {
for (const message of messages) {
await this.pinMessageFromQuickActions(message, unpin);
}
}
/**
* Pin the given message from the contextual menu
* @param message
*/
async pinMessage(message: string) {
const timelineMessage = this.page.locator(".mx_MTextBody", { hasText: message });
await timelineMessage.click({ button: "right" });
await this.page.getByRole("menuitem", { name: "Pin", exact: true }).click();
}
/**
* Pin the given messages
* @param messages
*/
async pinMessages(messages: string[]) {
for (const message of messages) {
await this.pinMessage(message);
}
}
/**
* Open the room info panel
*/
async openRoomInfo() {
await this.page.getByRole("button", { name: "Room info" }).nth(1).click();
}
/**
* Assert that the pinned count in the room info is correct
* Open the room info and check the pinned count
* @param count
*/
async assertPinnedCountInRoomInfo(count: number) {
await expect(this.page.getByRole("menuitem", { name: "Pinned messages" })).toHaveText(
`Pinned messages${count}`,
);
}
/**
* Open the pinned messages list
*/
async openPinnedMessagesList() {
await this.page.getByRole("menuitem", { name: "Pinned messages" }).click();
}
/**
* Return the right panel
*/
public getRightPanel() {
return this.page.locator("#mx_RightPanel");
}
/**
* Assert that the pinned message list contains the given messages
* @param messages
*/
async assertPinnedMessagesList(messages: string[]) {
const rightPanel = this.getRightPanel();
await expect(rightPanel.getByRole("heading", { name: "Pinned messages" })).toHaveText(
`${messages.length} Pinned messages`,
);
const list = rightPanel.getByRole("list");
await expect(list.getByRole("listitem")).toHaveCount(messages.length);
for (const message of messages) {
await expect(list.getByText(message)).toBeVisible();
}
}
/**
* Assert that the pinned message list is empty
*/
async assertEmptyPinnedMessagesList() {
const rightPanel = this.getRightPanel();
await expect(rightPanel).toMatchScreenshot(`pinned-messages-list-empty.png`);
}
/**
* Open the unpin all dialog
*/
async openUnpinAllDialog() {
await this.openRoomInfo();
await this.openPinnedMessagesList();
await this.page.getByRole("button", { name: "Unpin all" }).click();
}
/**
* Return the unpin all dialog
*/
getUnpinAllDialog() {
return this.page.locator(".mx_Dialog", { hasText: "Unpin all messages?" });
}
/**
* Click on the Continue button of the unoin all dialog
*/
async confirmUnpinAllDialog() {
await this.getUnpinAllDialog().getByRole("button", { name: "Continue" }).click();
}
/**
* Go back from the pinned messages list
*/
async backPinnedMessagesList() {
await this.page.locator("#mx_RightPanel").getByTestId("base-card-back-button").click();
}
/**
* Open the contextual menu of a message in the pin message list and click on unpin
* @param message
*/
async unpinMessageFromMessageList(message: string) {
const item = this.getRightPanel().getByRole("list").getByRole("listitem").filter({
hasText: message,
});
await item.getByRole("button").click();
await this.page.getByRole("menu", { name: "Open menu" }).getByRole("menuitem", { name: "Unpin" }).click();
}
/**
* Return the banner
* @private
*/
public getBanner() {
return this.page.getByTestId("pinned-message-banner");
}
/**
* Assert that the banner contains the given message
* @param msg
*/
async assertMessageInBanner(msg: string) {
await expect(this.getBanner().getByText(msg)).toBeVisible();
}
/**
* Return the view all button
*/
public getViewAllButton() {
return this.page.getByRole("button", { name: "View all" });
}
/**
* Return the close list button
*/
public getCloseListButton() {
return this.page.getByRole("button", { name: "Close list" });
}
}
export { expect };

View file

@ -0,0 +1,154 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import { test } from "./index";
import { expect } from "../../element-web-test";
test.describe("Pinned messages", () => {
test("should show the empty state when there are no pinned messages", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.openRoomInfo();
await util.assertPinnedCountInRoomInfo(0);
await util.openPinnedMessagesList();
await util.assertEmptyPinnedMessagesList();
});
test("should pin one message and to have the pinned message badge in the timeline", async ({
page,
app,
room1,
util,
}) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1"]);
await util.pinMessages(["Msg1"]);
const tile = util.getEventTile("Msg1");
await expect(tile).toMatchScreenshot("pinned-message-Msg1.png", {
mask: [tile.locator(".mx_MessageTimestamp")],
});
});
test("should pin messages and show them in the room info panel", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
await util.pinMessages(["Msg1", "Msg2", "Msg4"]);
await util.openRoomInfo();
await util.assertPinnedCountInRoomInfo(3);
});
test("should pin messages and show them in the pinned message panel", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
// Pin the messages
await util.pinMessages(["Msg1", "Msg2", "Msg4"]);
await util.openRoomInfo();
await util.openPinnedMessagesList();
await util.assertPinnedMessagesList(["Msg1", "Msg2", "Msg4"]);
});
test("should unpin one message", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
await util.pinMessages(["Msg1", "Msg2", "Msg4"]);
await util.openRoomInfo();
await util.openPinnedMessagesList();
await util.unpinMessageFromMessageList("Msg2");
await util.assertPinnedMessagesList(["Msg1", "Msg4"]);
await util.backPinnedMessagesList();
await util.assertPinnedCountInRoomInfo(2);
});
test("should unpin all messages", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
await util.pinMessages(["Msg1", "Msg2", "Msg4"]);
await util.openUnpinAllDialog();
await expect(util.getUnpinAllDialog()).toMatchScreenshot("unpin-all-dialog.png");
await util.confirmUnpinAllDialog();
await util.assertEmptyPinnedMessagesList();
await util.backPinnedMessagesList();
await util.assertPinnedCountInRoomInfo(0);
});
test("should be able to pin and unpin from the quick actions", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
await util.pinMessagesFromQuickActions(["Msg1"]);
await util.openRoomInfo();
await util.assertPinnedCountInRoomInfo(1);
await util.pinMessagesFromQuickActions(["Msg1"], true);
await util.assertPinnedCountInRoomInfo(0);
});
test("should display one message in the banner", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1"]);
await util.pinMessages(["Msg1"]);
await util.assertMessageInBanner("Msg1");
await expect(util.getBanner()).toMatchScreenshot("pinned-message-banner-1-Msg1.png");
});
test("should display 2 messages in the banner", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1", "Msg2"]);
await util.pinMessages(["Msg1", "Msg2"]);
await util.assertMessageInBanner("Msg2");
await expect(util.getBanner()).toMatchScreenshot("pinned-message-banner-2-Msg2.png");
await util.getBanner().click();
await util.assertMessageInBanner("Msg1");
await expect(util.getBanner()).toMatchScreenshot("pinned-message-banner-2-Msg1.png");
await util.getBanner().click();
await util.assertMessageInBanner("Msg2");
await expect(util.getBanner()).toMatchScreenshot("pinned-message-banner-2-Msg2.png");
});
test("should display 4 messages in the banner", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
await util.pinMessages(["Msg1", "Msg2", "Msg3", "Msg4"]);
for (const msg of ["Msg4", "Msg3", "Msg2", "Msg1"]) {
await util.assertMessageInBanner(msg);
await expect(util.getBanner()).toMatchScreenshot(`pinned-message-banner-4-${msg}.png`);
await util.getBanner().click();
}
});
test("should open the pinned messages list from the banner", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1", "Msg2"]);
await util.pinMessages(["Msg1", "Msg2"]);
await util.getViewAllButton().click();
await util.assertPinnedMessagesList(["Msg1", "Msg2"]);
await expect(util.getCloseListButton()).toBeVisible();
});
test("banner should listen to pinned message list", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1", "Msg2"]);
await util.pinMessages(["Msg1", "Msg2"]);
await expect(util.getViewAllButton()).toBeVisible();
await util.openRoomInfo();
await util.openPinnedMessagesList();
await expect(util.getCloseListButton()).toBeVisible();
});
});

View file

@ -0,0 +1,142 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import type { Bot } from "../../pages/bot";
import type { Client } from "../../pages/client";
import { ElementAppPage } from "../../pages/ElementAppPage";
test.describe("Poll history", () => {
type CreatePollOptions = {
title: string;
options: {
"id": string;
"org.matrix.msc1767.text": string;
}[];
};
const createPoll = async (createOptions: CreatePollOptions, roomId: string, client: Client) => {
return client.sendEvent(roomId, null, "org.matrix.msc3381.poll.start", {
"org.matrix.msc3381.poll.start": {
question: {
"org.matrix.msc1767.text": createOptions.title,
"body": createOptions.title,
"msgtype": "m.text",
},
kind: "org.matrix.msc3381.poll.disclosed",
max_selections: 1,
answers: createOptions.options,
},
"org.matrix.msc1767.text": "poll fallback text",
});
};
const botVoteForOption = async (bot: Bot, roomId: string, pollId: string, optionId: string): Promise<void> => {
// We can't use the js-sdk types for this stuff directly, so manually construct the event.
await bot.sendEvent(roomId, null, "org.matrix.msc3381.poll.response", {
"m.relates_to": {
rel_type: "m.reference",
event_id: pollId,
},
"org.matrix.msc3381.poll.response": {
answers: [optionId],
},
});
};
const endPoll = async (bot: Bot, roomId: string, pollId: string): Promise<void> => {
// We can't use the js-sdk types for this stuff directly, so manually construct the event.
await bot.sendEvent(roomId, null, "org.matrix.msc3381.poll.end", {
"m.relates_to": {
rel_type: "m.reference",
event_id: pollId,
},
"org.matrix.msc1767.text": "The poll has ended",
});
};
async function openPollHistory(app: ElementAppPage): Promise<void> {
const { page } = app;
await app.toggleRoomInfoPanel();
await page.locator(".mx_RoomSummaryCard").getByRole("menuitem", { name: "Polls" }).click();
}
test.use({
displayName: "Tom",
botCreateOpts: { displayName: "BotBob" },
});
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
// Collapse left panel for these tests
window.localStorage.setItem("mx_lhs_size", "0");
});
});
test("Should display active and past polls", async ({ page, app, user, bot }) => {
const pollParams1 = {
title: "Does the polls feature work?",
options: ["Yes", "No", "Maybe"].map((option) => ({
"id": option,
"org.matrix.msc1767.text": option,
})),
};
const pollParams2 = {
title: "Which way",
options: ["Left", "Right"].map((option) => ({
"id": option,
"org.matrix.msc1767.text": option,
})),
};
const roomId = await app.client.createRoom({});
await app.client.inviteUser(roomId, bot.credentials.userId);
await page.goto("/#/room/" + roomId);
// wait until Bob joined
await expect(page.getByText("BotBob joined the room")).toBeAttached();
// active poll
const { event_id: pollId1 } = await createPoll(pollParams1, roomId, bot);
await botVoteForOption(bot, roomId, pollId1, pollParams1.options[1].id);
// ended poll
const { event_id: pollId2 } = await createPoll(pollParams2, roomId, bot);
await botVoteForOption(bot, roomId, pollId2, pollParams1.options[1].id);
await endPoll(bot, roomId, pollId2);
await openPollHistory(app);
// these polls are also in the timeline
// focus on the poll history dialog
const dialog = page.locator(".mx_Dialog");
// active poll is in active polls list
// open poll detail
await dialog.getByText(pollParams1.title).click();
await dialog.getByText("Yes").click();
// vote in the poll
await expect(dialog.getByTestId("totalVotes").getByText("Based on 2 votes")).toBeAttached();
// navigate back to list
await dialog.locator(".mx_PollHistory_header").getByRole("button", { name: "Active polls" }).click();
// go to past polls list
await dialog.getByText("Past polls").click();
await expect(dialog.getByText(pollParams2.title)).toBeAttached();
// end poll1 while dialog is open
await endPoll(bot, roomId, pollId1);
await expect(dialog.getByText(pollParams2.title)).toBeAttached();
await expect(dialog.getByText(pollParams1.title)).toBeAttached();
dialog.getByText("Active polls").click();
// no more active polls
await expect(page.getByText("There are no active polls in this room")).toBeAttached();
});
});

View file

@ -0,0 +1,325 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { Bot } from "../../pages/bot";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout";
import type { Locator, Page } from "@playwright/test";
test.describe("Polls", () => {
type CreatePollOptions = {
title: string;
options: string[];
};
const createPoll = async (page: Page, { title, options }: CreatePollOptions) => {
if (options.length < 2) {
throw new Error("Poll must have at least two options");
}
const dialog = page.locator(".mx_PollCreateDialog");
await dialog.getByRole("textbox", { name: "Question or topic" }).fill(title);
for (const [index, value] of options.entries()) {
const optionIdLocator = dialog.locator(`#pollcreate_option_${index}`);
// click 'add option' button if needed
if ((await optionIdLocator.count()) === 0) {
const button = dialog.getByRole("button", { name: "Add option" });
await button.scrollIntoViewIfNeeded();
await button.click();
}
await optionIdLocator.scrollIntoViewIfNeeded();
await optionIdLocator.fill(value);
}
await page.locator(".mx_Dialog").getByRole("button", { name: "Create Poll" }).click();
};
const getPollTile = (page: Page, pollId: string, optLocator?: Locator): Locator => {
return (optLocator ?? page).locator(`.mx_EventTile[data-scroll-tokens="${pollId}"]`);
};
const getPollOption = (page: Page, pollId: string, optionText: string, optLocator?: Locator): Locator => {
return getPollTile(page, pollId, optLocator)
.locator(".mx_PollOption .mx_StyledRadioButton")
.filter({ hasText: optionText });
};
const expectPollOptionVoteCount = async (
page: Page,
pollId: string,
optionText: string,
votes: number,
optLocator?: Locator,
): Promise<void> => {
await expect(
getPollOption(page, pollId, optionText, optLocator).locator(".mx_PollOption_optionVoteCount"),
).toContainText(`${votes} vote`);
};
const botVoteForOption = async (
page: Page,
bot: Bot,
roomId: string,
pollId: string,
optionText: string,
): Promise<void> => {
const locator = getPollOption(page, pollId, optionText);
const optionId = await locator.first().getByRole("radio").getAttribute("value");
// We can't use the js-sdk types for this stuff directly, so manually construct the event.
await bot.sendEvent(roomId, null, "org.matrix.msc3381.poll.response", {
"m.relates_to": {
rel_type: "m.reference",
event_id: pollId,
},
"org.matrix.msc3381.poll.response": {
answers: [optionId],
},
});
};
test.use({
displayName: "Tom",
botCreateOpts: { displayName: "BotBob" },
});
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
// Collapse left panel for these tests
window.localStorage.setItem("mx_lhs_size", "0");
});
});
test("should be creatable and votable", async ({ page, app, bot, user }) => {
const roomId: string = await app.client.createRoom({});
await app.client.inviteUser(roomId, bot.credentials.userId);
await page.goto("/#/room/" + roomId);
// wait until Bob joined
await expect(page.getByText("BotBob joined the room")).toBeAttached();
const locator = await app.openMessageComposerOptions();
await locator.getByRole("menuitem", { name: "Poll" }).click();
// Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688
//cy.get(".mx_CompoundDialog").percySnapshotElement("Polls Composer");
const pollParams = {
title: "Does the polls feature work?",
options: ["Yes", "No", "Maybe?"],
};
await createPoll(page, pollParams);
// Wait for message to send, get its ID and save as @pollId
const pollId = await page
.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]")
.filter({ hasText: pollParams.title })
.getAttribute("data-scroll-tokens");
await expect(getPollTile(page, pollId)).toMatchScreenshot("Polls_Timeline_tile_no_votes.png", {
mask: [page.locator(".mx_MessageTimestamp")],
});
// Bot votes 'Maybe' in the poll
await botVoteForOption(page, bot, roomId, pollId, pollParams.options[2]);
// no votes shown until I vote, check bots vote has arrived
await expect(
page.locator(".mx_MPollBody_totalVotes").getByText("1 vote cast. Vote to see the results"),
).toBeAttached();
// vote 'Maybe'
await getPollOption(page, pollId, pollParams.options[2]).click();
// both me and bot have voted Maybe
await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 2);
// change my vote to 'Yes'
await getPollOption(page, pollId, pollParams.options[0]).click();
// 1 vote for yes
await expectPollOptionVoteCount(page, pollId, pollParams.options[0], 1);
// 1 vote for maybe
await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 1);
// Bot updates vote to 'No'
await botVoteForOption(page, bot, roomId, pollId, pollParams.options[1]);
// 1 vote for yes
await expectPollOptionVoteCount(page, pollId, pollParams.options[0], 1);
// 1 vote for no
await expectPollOptionVoteCount(page, pollId, pollParams.options[0], 1);
// 0 for maybe
await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 0);
});
test("should be editable from context menu if no votes have been cast", async ({ page, app, user, bot }) => {
const roomId: string = await app.client.createRoom({});
await app.client.inviteUser(roomId, bot.credentials.userId);
await page.goto("/#/room/" + roomId);
const locator = await app.openMessageComposerOptions();
await locator.getByRole("menuitem", { name: "Poll" }).click();
const pollParams = {
title: "Does the polls feature work?",
options: ["Yes", "No", "Maybe"],
};
await createPoll(page, pollParams);
// Wait for message to send, get its ID and save as @pollId
const pollId = await page
.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]")
.filter({ hasText: pollParams.title })
.getAttribute("data-scroll-tokens");
// Open context menu
await getPollTile(page, pollId).click({ button: "right" });
// Select edit item
await page.getByRole("menuitem", { name: "Edit" }).click();
// Expect poll editing dialog
await expect(page.locator(".mx_PollCreateDialog")).toBeAttached();
});
test("should not be editable from context menu if votes have been cast", async ({ page, app, user, bot }) => {
const roomId: string = await app.client.createRoom({});
await app.client.inviteUser(roomId, bot.credentials.userId);
await page.goto("/#/room/" + roomId);
const locator = await app.openMessageComposerOptions();
await locator.getByRole("menuitem", { name: "Poll" }).click();
const pollParams = {
title: "Does the polls feature work?",
options: ["Yes", "No", "Maybe"],
};
await createPoll(page, pollParams);
// Wait for message to send, get its ID and save as @pollId
const pollId = await page
.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]")
.filter({ hasText: pollParams.title })
.getAttribute("data-scroll-tokens");
// Bot votes 'Maybe' in the poll
await botVoteForOption(page, bot, roomId, pollId, pollParams.options[2]);
// wait for bot's vote to arrive
await expect(page.locator(".mx_MPollBody_totalVotes")).toContainText("1 vote cast");
// Open context menu
await getPollTile(page, pollId).click({ button: "right" });
// Select edit item
await page.getByRole("menuitem", { name: "Edit" }).click();
// Expect poll editing dialog
await expect(page.locator(".mx_ErrorDialog")).toBeAttached();
});
test("should be displayed correctly in thread panel", async ({ page, app, user, bot, homeserver }) => {
const botCharlie = new Bot(page, homeserver, { displayName: "BotCharlie" });
await botCharlie.prepareClient();
const roomId: string = await app.client.createRoom({});
await app.client.inviteUser(roomId, bot.credentials.userId);
await app.client.inviteUser(roomId, botCharlie.credentials.userId);
await page.goto("/#/room/" + roomId);
// wait until the bots joined
await expect(page.getByText("BotBob and one other were invited and joined")).toBeAttached({ timeout: 10000 });
const locator = await app.openMessageComposerOptions();
await locator.getByRole("menuitem", { name: "Poll" }).click();
const pollParams = {
title: "Does the polls feature work?",
options: ["Yes", "No", "Maybe"],
};
await createPoll(page, pollParams);
// Wait for message to send, get its ID and save as @pollId
const pollId = await page
.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]")
.filter({ hasText: pollParams.title })
.getAttribute("data-scroll-tokens");
// Bob starts thread on the poll
await bot.sendMessage(
roomId,
{
body: "Hello there",
msgtype: "m.text",
},
pollId,
);
// open the thread summary
await page.getByRole("button", { name: "Open thread" }).click();
// Bob votes 'Maybe' in the poll
await botVoteForOption(page, bot, roomId, pollId, pollParams.options[2]);
// Charlie votes 'No'
await botVoteForOption(page, botCharlie, roomId, pollId, pollParams.options[1]);
// no votes shown until I vote, check votes have arrived in main tl
await expect(
page
.locator(".mx_RoomView_body .mx_MPollBody_totalVotes")
.getByText("2 votes cast. Vote to see the results"),
).toBeAttached();
// and thread view
await expect(
page.locator(".mx_ThreadView .mx_MPollBody_totalVotes").getByText("2 votes cast. Vote to see the results"),
).toBeAttached();
// Take snapshots of poll on ThreadView
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble']").first()).toBeVisible();
await expect(page.locator(".mx_ThreadView")).toMatchScreenshot("ThreadView_with_a_poll_on_bubble_layout.png", {
mask: [page.locator(".mx_MessageTimestamp")],
});
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='group']").first()).toBeVisible();
await expect(page.locator(".mx_ThreadView")).toMatchScreenshot("ThreadView_with_a_poll_on_group_layout.png", {
mask: [page.locator(".mx_MessageTimestamp")],
});
const roomViewLocator = page.locator(".mx_RoomView_body");
// vote 'Maybe' in the main timeline poll
await getPollOption(page, pollId, pollParams.options[2], roomViewLocator).click();
// both me and bob have voted Maybe
await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 2, roomViewLocator);
const threadViewLocator = page.locator(".mx_ThreadView");
// votes updated in thread view too
await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 2, threadViewLocator);
// change my vote to 'Yes'
await getPollOption(page, pollId, pollParams.options[0], threadViewLocator).click();
// Bob updates vote to 'No'
await botVoteForOption(page, bot, roomId, pollId, pollParams.options[1]);
// me: yes, bob: no, charlie: no
const expectVoteCounts = async (optLocator: Locator) => {
// I voted yes
await expectPollOptionVoteCount(page, pollId, pollParams.options[0], 1, optLocator);
// Bob and Charlie voted no
await expectPollOptionVoteCount(page, pollId, pollParams.options[1], 2, optLocator);
// 0 for maybe
await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 0, optLocator);
};
// check counts are correct in main timeline tile
await expectVoteCounts(page.locator(".mx_RoomView_body"));
// and in thread view tile
await expectVoteCounts(page.locator(".mx_ThreadView"));
});
});

View file

@ -0,0 +1,60 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Presence tests", () => {
test.use({
displayName: "Janet",
botCreateOpts: { displayName: "Bob" },
});
test.describe("bob unreachable", () => {
// This is failing on CI (https://github.com/element-hq/element-web/issues/27270)
// but not locally, so debugging this is going to be tricky. Let's disable it for now.
test.skip("renders unreachable presence state correctly", async ({ page, app, user, bot: bob }) => {
await app.client.createRoom({ name: "My Room", invite: [bob.credentials.userId] });
await app.viewRoomByName("My Room");
await bob.evaluate(async (client) => {
client.stopClient();
});
await page.route(
`**/sync*`,
async (route) => {
const response = await route.fetch();
await route.fulfill({
json: {
...(await response.json()),
presence: {
events: [
{
type: "m.presence",
sender: bob.credentials.userId,
content: {
presence: "io.element.unreachable",
currently_active: false,
},
},
],
},
},
});
},
{ times: 1 },
);
await app.client.createRoom({}); // trigger sync
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,183 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* 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,172 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* 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,171 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* 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

@ -0,0 +1,338 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* See readme.md for tips on writing these tests. */
import { customEvent, many, test } from ".";
test.describe("Read receipts", () => {
test.describe("Ignored events", () => {
test("If all events after receipt are unimportant, the room is read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
}) => {
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, [customEvent("org.custom.event", { body: "foobar" })]);
await util.assertRead(room2);
});
test("Sending an important event after unimportant ones makes the room unread", async ({
roomAlpha: room1,
roomBeta: room2,
util,
}) => {
// Given We have read the important messages
await util.goTo(room1);
await util.assertRead(room2);
await util.receiveMessages(room2, ["Msg1", "Msg2"]);
await util.assertUnread(room2, 2);
await util.goTo(room2);
await util.assertRead(room2);
await util.goTo(room1);
// When we receive unimportant messages
await util.receiveMessages(room2, [customEvent("org.custom.event", { body: "foobar" })]);
// Then the room is still read
await util.assertStillRead(room2);
// And when we receive more important ones
await util.receiveMessages(room2, ["Hello"]);
// The room is unread again
await util.assertUnread(room2, 1);
});
test("A receipt for the last unimportant event makes the room read, even if all are unimportant", async ({
roomAlpha: room1,
roomBeta: room2,
util,
}) => {
// Display room 1
await util.goTo(room1);
// The room 2 is read
await util.assertRead(room2);
// We received 3 unimportant messages to room2
await util.receiveMessages(room2, [
customEvent("org.custom.event", { body: "foobar1" }),
customEvent("org.custom.event", { body: "foobar2" }),
customEvent("org.custom.event", { body: "foobar3" }),
]);
// The room 2 is still read
await util.assertStillRead(room2);
});
});
test.describe("Paging up", () => {
test("Paging up through old messages after a room is read leaves the room read", async ({
page,
roomAlpha: room1,
roomBeta: room2,
util,
}) => {
// Given lots of messages are in the room, but we have read them
await util.goTo(room1);
await util.receiveMessages(room2, many("Msg", 110));
await util.assertUnread(room2, 110);
await util.goTo(room2);
await util.assertRead(room2);
await util.goTo(room1);
// When we restart, so only recent messages are loaded
await util.saveAndReload();
await util.goTo(room2);
await util.assertMessageNotLoaded("Msg0010");
// And we page up, loading in old messages
await util.pageUp();
await page.waitForTimeout(200);
await util.pageUp();
await page.waitForTimeout(200);
await util.pageUp();
await util.assertMessageLoaded("Msg0010");
// Then the room remains read
await util.assertStillRead(room2);
});
test("Paging up through old messages of an unread room leaves the room unread", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given lots of messages are in the room, and they are not read
await util.goTo(room1);
await util.receiveMessages(room2, many("x\ny\nz\nMsg", 40)); // newline to spread out messages
await util.assertUnread(room2, 40);
// When I jump to a message in the middle and page up
await msg.jumpTo(room2.name, "x\ny\nz\nMsg0020");
await util.pageUp();
// Then the room is still unread
await util.assertUnreadGreaterThan(room2, 1);
});
test("Paging up to find old threads that were previously read leaves the room read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
test.slow();
// Given lots of messages in threads are all read
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root1",
"Root2",
"Root3",
...msg.manyThreadedOff("Root1", many("T", 20)),
...msg.manyThreadedOff("Root2", many("T", 20)),
...msg.manyThreadedOff("Root3", many("T", 20)),
]);
await util.goTo(room2);
await util.assertRead(room2);
await util.assertUnreadThread("Root1");
await util.assertUnreadThread("Root2");
await util.assertUnreadThread("Root3");
await util.openThread("Root1");
await util.assertReadThread("Root1");
await util.openThread("Root2");
await util.assertReadThread("Root2");
await util.openThread("Root3");
await util.assertReadThread("Root3");
// When I restart and page up to load old thread roots
await util.goTo(room1);
await util.saveAndReload();
await util.goTo(room2);
await util.pageUp();
// Then the room and threads remain read
await util.assertRead(room2);
await util.assertReadThread("Root1");
await util.assertReadThread("Root2");
await util.assertReadThread("Root3");
});
test("Paging up to find old threads that were never read keeps the room unread", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
test.slow();
// Given lots of messages in threads that are unread
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root1",
"Root2",
"Root3",
...msg.manyThreadedOff("Root1", many("T", 2)),
...msg.manyThreadedOff("Root2", many("T", 2)),
...msg.manyThreadedOff("Root3", many("T", 2)),
...many("Msg", 100),
]);
await util.goTo(room2);
await util.assertRead(room2);
await util.assertUnreadThread("Root1");
await util.assertUnreadThread("Root2");
await util.assertUnreadThread("Root3");
// When I restart
await util.closeThreadsPanel();
await util.goTo(room1);
await util.saveAndReload();
// Then the room remembers it's read
// TODO: I (andyb) think this will fall in an encrypted room
await util.assertRead(room2);
// And when I page up to load old thread roots
await util.goTo(room2);
await util.pageUp();
// Then the room remains read
await util.assertRead(room2);
await util.assertUnreadThread("Root1");
await util.assertUnreadThread("Root2");
await util.assertUnreadThread("Root3");
});
test("Looking in thread view to find old threads that were never read makes the room unread", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given lots of messages in threads that are unread
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root1",
"Root2",
"Root3",
...msg.manyThreadedOff("Root1", many("T", 2)),
...msg.manyThreadedOff("Root2", many("T", 2)),
...msg.manyThreadedOff("Root3", many("T", 2)),
...many("Msg", 100),
]);
await util.goTo(room2);
await util.assertRead(room2);
await util.assertUnreadThread("Root1");
await util.assertUnreadThread("Root2");
await util.assertUnreadThread("Root3");
// When I restart
await util.closeThreadsPanel();
await util.goTo(room1);
await util.saveAndReload();
// Then the room remembers it's read
// TODO: I (andyb) think this will fall in an encrypted room
await util.assertRead(room2);
// And when I open the threads view
await util.goTo(room2);
await util.openThreadList();
// Then the room remains read
await util.assertRead(room2);
await util.assertUnreadThread("Root1");
await util.assertUnreadThread("Root2");
await util.assertUnreadThread("Root3");
});
test("After marking room as read, paging up to find old threads that were never read leaves the room read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
test.slow();
// Given lots of messages in threads that are unread but I marked as read on a main timeline message
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root1",
"Root2",
"Root3",
...msg.manyThreadedOff("Root1", many("T", 2)),
...msg.manyThreadedOff("Root2", many("T", 2)),
...msg.manyThreadedOff("Root3", many("T", 2)),
...many("Msg", 100),
]);
await util.markAsRead(room2);
await util.assertRead(room2);
// When I restart
await util.saveAndReload();
// Then the room remembers it's read
await util.assertRead(room2);
// And when I page up to load old thread roots
await util.goTo(room2);
await util.pageUp();
await util.pageUp();
await util.pageUp();
// Then the room remains read
await util.assertStillRead(room2);
await util.assertReadThread("Root1");
await util.assertReadThread("Root2");
await util.assertReadThread("Root3");
});
test("After marking room as read based on a thread message, opening threads view to find old threads that were never read leaves the room read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given lots of messages in threads that are unread but I marked as read on a thread message
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root1",
"Root2",
"Root3",
...msg.manyThreadedOff("Root1", many("T1-", 2)),
...msg.manyThreadedOff("Root2", many("T2-", 2)),
...msg.manyThreadedOff("Root3", many("T3-", 2)),
...many("Msg", 100),
msg.threadedOff("Msg0099", "Thread off 99"),
]);
await util.markAsRead(room2);
await util.assertRead(room2);
// When I restart
await util.saveAndReload();
// Then the room remembers it's read
await util.assertRead(room2);
// And when I page up to load old thread roots
await util.goTo(room2);
await util.openThreadList();
// Then the room remains read
await util.assertStillRead(room2);
await util.assertReadThread("Root1");
await util.assertReadThread("Root2");
await util.assertReadThread("Root3");
});
});
});

View file

@ -0,0 +1,648 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import type { JSHandle, Page } from "@playwright/test";
import type { MatrixEvent, Room, IndexedDBStore, ReceiptType } from "matrix-js-sdk/src/matrix";
import { test as base, expect } from "../../element-web-test";
import { Bot } from "../../pages/bot";
import { Client } from "../../pages/client";
import { ElementAppPage } from "../../pages/ElementAppPage";
/**
* Set up for a read receipt test:
* - Create a user with the supplied name
* - As that user, create two rooms with the supplied names
* - Create a bot with the supplied name
* - Invite the bot to both rooms and ensure that it has joined
*/
export const test = base.extend<{
roomAlphaName?: string;
roomAlpha: { name: string; roomId: string };
roomBetaName?: string;
roomBeta: { name: string; roomId: string };
msg: MessageBuilder;
util: Helpers;
}>({
displayName: "Mae",
botCreateOpts: { displayName: "Other User" },
roomAlphaName: "Room Alpha",
roomAlpha: async ({ roomAlphaName: name, app, user, bot }, use) => {
const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] });
await use({ name, roomId });
},
roomBetaName: "Room Beta",
roomBeta: async ({ roomBetaName: name, app, user, bot }, use) => {
const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] });
await use({ name, roomId });
},
msg: async ({ page, app, util }, use) => {
await use(new MessageBuilder(page, app, util));
},
util: async ({ roomAlpha, roomBeta, page, app, bot }, use) => {
await use(new Helpers(page, app, bot));
},
});
/**
* A utility that is able to find messages based on their content, by looking
* inside the `timeline` objects in the object model.
*
* Crucially, we hold on to references to events that have been edited or
* redacted, so we can still look them up by their old content.
*
* Provides utilities that build on the ability to find messages, e.g. replyTo,
* which finds a message and then constructs a reply to it.
*/
export class MessageBuilder {
constructor(
private page: Page,
private app: ElementAppPage,
private helpers: Helpers,
) {}
/**
* Map of message content -> event.
*/
messages = new Map<String, Promise<JSHandle<MatrixEvent>>>();
/**
* Utility to find a MatrixEvent by its body content
* @param room - the room to search for the event in
* @param message - the body of the event to search for
* @param includeThreads - whether to search within threads too
*/
async getMessage(room: JSHandle<Room>, message: string, includeThreads = false): Promise<JSHandle<MatrixEvent>> {
const cached = this.messages.get(message);
if (cached) {
return cached;
}
const promise = room.evaluateHandle(
async (room, { message, includeThreads }) => {
let ev = room.timeline.find((e) => e.getContent().body === message);
if (!ev && includeThreads) {
for (const thread of room.getThreads()) {
ev = thread.timeline.find((e) => e.getContent().body === message);
if (ev) break;
}
}
if (ev) return ev;
return new Promise<MatrixEvent>((resolve) => {
room.on("Room.timeline" as any, (ev: MatrixEvent) => {
if (ev.getContent().body === message) {
resolve(ev);
}
});
});
},
{ message, includeThreads },
);
this.messages.set(message, promise);
return promise;
}
/**
* MessageContentSpec to send an edit into a room
* @param originalMessage - the body of the message to edit
* @param newMessage - the message body to send in the edit
*/
editOf(originalMessage: string, newMessage: string): MessageContentSpec {
return new (class extends MessageContentSpec {
public async getContent(room: JSHandle<Room>): Promise<Record<string, unknown>> {
const ev = await this.messageFinder.getMessage(room, originalMessage, true);
return ev.evaluate((ev, newMessage) => {
// If this event has been redacted, its msgtype will be
// undefined. In that case, we guess msgtype as m.text.
const msgtype = ev.getContent().msgtype ?? "m.text";
return {
"msgtype": msgtype,
"body": `* ${newMessage}`,
"m.new_content": {
msgtype: msgtype,
body: newMessage,
},
"m.relates_to": {
rel_type: "m.replace",
event_id: ev.getId(),
},
};
}, newMessage);
}
})(this);
}
/**
* MessageContentSpec to send a reply into a room
* @param targetMessage - the body of the message to reply to
* @param newMessage - the message body to send into the reply
*/
replyTo(targetMessage: string, newMessage: string): MessageContentSpec {
return new (class extends MessageContentSpec {
public async getContent(room: JSHandle<Room>): Promise<Record<string, unknown>> {
const ev = await this.messageFinder.getMessage(room, targetMessage, true);
return ev.evaluate((ev, newMessage) => {
const threadRel =
ev.getRelation()?.rel_type === "m.thread"
? {
rel_type: "m.thread",
event_id: ev.getRelation().event_id,
}
: {};
return {
"msgtype": "m.text",
"body": newMessage,
"m.relates_to": {
...threadRel,
"m.in_reply_to": {
event_id: ev.getId(),
},
},
};
}, newMessage);
}
})(this);
}
/**
* MessageContentSpec to send a threaded response into a room
* @param rootMessage - the body of the thread root message to send a response to
* @param newMessage - the message body to send into the thread response
*/
threadedOff(rootMessage: string, newMessage: string): MessageContentSpec {
return new (class extends MessageContentSpec {
public async getContent(room: JSHandle<Room>): Promise<Record<string, unknown>> {
const ev = await this.messageFinder.getMessage(room, rootMessage);
return ev.evaluate((ev, newMessage) => {
return {
"msgtype": "m.text",
"body": newMessage,
"m.relates_to": {
event_id: ev.getId(),
is_falling_back: true,
rel_type: "m.thread",
},
};
}, newMessage);
}
})(this);
}
/**
* Generate MessageContentSpecs to send multiple threaded responses into a room.
*
* @param rootMessage - the body of the thread root message to send a response to
* @param newMessages - the contents of the messages
*/
manyThreadedOff(rootMessage: string, newMessages: Array<string>): Array<MessageContentSpec> {
return newMessages.map((body) => this.threadedOff(rootMessage, body));
}
/**
* BotActionSpec to send a reaction to an existing event into a room
* @param targetMessage - the body of the message to send a reaction to
* @param reaction - the key of the reaction to send into the room
*/
reactionTo(targetMessage: string, reaction: string): BotActionSpec {
return new (class extends BotActionSpec {
public async performAction(bot: Bot, room: JSHandle<Room>): Promise<void> {
const ev = await this.messageFinder.getMessage(room, targetMessage, true);
const { id, threadId } = await ev.evaluate((ev) => ({
id: ev.getId(),
threadId: !ev.isThreadRoot ? ev.threadRootId : undefined,
}));
const roomId = await room.evaluate((room) => room.roomId);
await bot.sendEvent(roomId, threadId ?? null, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: id,
key: reaction,
},
});
}
})(this);
}
/**
* BotActionSpec to send a redaction into a room
* @param targetMessage - the body of the message to send a redaction to
*/
redactionOf(targetMessage: string): BotActionSpec {
return new (class extends BotActionSpec {
public async performAction(bot: Bot, room: JSHandle<Room>): Promise<void> {
const ev = await this.messageFinder.getMessage(room, targetMessage, true);
const { id, threadId } = await ev.evaluate((ev) => ({
id: ev.getId(),
threadId: !ev.isThreadRoot ? ev.threadRootId : undefined,
}));
const roomId = await room.evaluate((room) => room.roomId);
await bot.redactEvent(roomId, threadId, id);
}
})(this);
}
/**
* Find and display a message.
*
* @param roomName the name of the room to look inside
* @param message the content of the message to fine
* @param includeThreads look for messages inside threads, not just the main timeline
*/
async jumpTo(roomName: string, message: string, includeThreads = false) {
const room = await this.helpers.findRoomByName(roomName);
const foundMessage = await this.getMessage(room, message, includeThreads);
const roomId = await room.evaluate((room) => room.roomId);
const foundMessageId = await foundMessage.evaluate((ev) => ev.getId());
await this.page.goto(`/#/room/${roomId}/${foundMessageId}`);
}
async sendThreadedReadReceipt(room: JSHandle<Room>, targetMessage: string) {
const event = await this.getMessage(room, targetMessage, true);
await this.app.client.evaluate(
(client, { event }) => {
return client.sendReadReceipt(event);
},
{ event },
);
}
async sendUnthreadedReadReceipt(room: JSHandle<Room>, targetMessage: string) {
const event = await this.getMessage(room, targetMessage, true);
await this.app.client.evaluate(
(client, { event }) => {
return client.sendReadReceipt(event, "m.read" as any as ReceiptType, true);
},
{ event },
);
}
}
/**
* Something that can provide the content of a message.
*
* For example, we return and instance of this from {@link
* MessageBuilder.replyTo} which creates a reply based on a previous message.
*/
export abstract class MessageContentSpec {
messageFinder: MessageBuilder | null;
constructor(messageFinder: MessageBuilder = null) {
this.messageFinder = messageFinder;
}
public abstract getContent(room: JSHandle<Room>): Promise<Record<string, unknown>>;
}
/**
* Something that can perform an action at the time we would usually send a
* message.
*
* For example, we return an instance of this from {@link
* MessageBuilder.redactionOf} which redacts the message we are referring to.
*/
export abstract class BotActionSpec {
messageFinder: MessageBuilder | null;
constructor(messageFinder: MessageBuilder = null) {
this.messageFinder = messageFinder;
}
public abstract performAction(client: Client, room: JSHandle<Room>): Promise<void>;
}
/**
* Something that we will turn into a message or event when we pass it in to
* e.g. receiveMessages.
*/
export type Message = string | MessageContentSpec | BotActionSpec;
class Helpers {
constructor(
private page: Page,
private app: ElementAppPage,
private bot: Bot,
) {}
/**
* Use the supplied client to send messages or perform actions as specified by
* the supplied {@link Message} items.
*/
async sendMessageAsClient(cli: Client, roomName: string | { name: string }, messages: Message[]) {
const room = await this.findRoomByName(typeof roomName === "string" ? roomName : roomName.name);
const roomId = await room.evaluate((room) => room.roomId);
for (const message of messages) {
if (typeof message === "string") {
await cli.sendMessage(roomId, { body: message, msgtype: "m.text" });
} else if (message instanceof MessageContentSpec) {
await cli.sendMessage(roomId, await message.getContent(room));
} else {
await message.performAction(cli, room);
}
// TODO: without this wait, some tests that send lots of messages flake
// from time to time. I (andyb) have done some investigation, but it
// needs more work to figure out. The messages do arrive over sync, but
// they never appear in the timeline, and they never fire a
// Room.timeline event. I think this only happens with events that refer
// to other events (e.g. replies), so it might be caused by the
// referring event arriving before the referred-to event.
await this.page.waitForTimeout(100);
}
}
/**
* Open the room with the supplied name.
*/
async goTo(room: string | { name: string }) {
await this.app.viewRoomByName(typeof room === "string" ? room : room.name);
}
/**
* Expand the message with the supplied index in the timeline.
* @param index
*/
async openCollapsedMessage(index: number) {
const button = this.page.locator(".mx_GenericEventListSummary_toggle");
await button.nth(index).click();
}
/**
* Click the thread with the supplied content in the thread root to open it in
* the Threads panel.
*/
async openThread(rootMessage: string) {
const tile = this.page.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", { hasText: rootMessage });
await tile.hover();
await tile.getByRole("button", { name: "Reply in thread" }).click();
await expect(this.page.locator(".mx_ThreadView_timelinePanelWrapper")).toBeVisible();
}
/**
* Close the threads panel.
*/
async closeThreadsPanel() {
await this.page.locator(".mx_RightPanel").getByTestId("base-card-close-button").click();
await expect(this.page.locator(".mx_RightPanel")).not.toBeVisible();
}
/**
* Return to the list of threads, given we are viewing a single thread.
*/
async backToThreadsList() {
await this.page.locator(".mx_RoomHeader").getByLabel("Threads").click();
}
/**
* Assert that the message containing the supplied text is visible in the UI.
* Note: matches part of the message content as well as the whole of it.
*/
async assertMessageLoaded(messagePart: string) {
await expect(this.page.locator(".mx_EventTile_body").getByText(messagePart)).toBeVisible();
}
/**
* Assert that the message containing the supplied text is not visible in the UI.
* Note: matches part of the message content as well as the whole of it.
*/
async assertMessageNotLoaded(messagePart: string) {
await expect(this.page.locator(".mx_EventTile_body").getByText(messagePart)).not.toBeVisible();
}
/**
* Scroll the messages panel up 1000 pixels.
*/
async pageUp() {
await this.page.locator(".mx_RoomView_messagePanel").evaluateAll((messagePanels) => {
messagePanels.forEach((messagePanel) => (messagePanel.scrollTop -= 1000));
});
}
getRoomListTile(room: string | { name: string }) {
const roomName = typeof room === "string" ? room : room.name;
return this.page.getByRole("treeitem", { name: new RegExp("^" + roomName) });
}
/**
* Click the "Mark as Read" context menu item on the room with the supplied name
* in the room list.
*/
async markAsRead(room: string | { name: string }) {
await this.getRoomListTile(room).click({ button: "right" });
await this.page.getByText("Mark as read").click();
}
/**
* Assert that the room with the supplied name is "read" in the room list - i.g.
* has not dot or count of unread messages.
*/
async assertRead(room: string | { name: string }) {
const tile = this.getRoomListTile(room);
await expect(tile.locator(".mx_NotificationBadge_dot")).not.toBeVisible();
await expect(tile.locator(".mx_NotificationBadge_count")).not.toBeVisible();
}
/**
* Assert that this room remains read, when it was previously read.
* (In practice, this just waits a short while to allow any unread marker to
* appear, and then asserts that the room is read.)
*/
async assertStillRead(room: string | { name: string }) {
await this.page.waitForTimeout(200);
await this.assertRead(room);
}
/**
* Assert a given room is marked as unread (via the room list tile)
* @param room - the name of the room to check
* @param count - the numeric count to assert, or if "." specified then a bold/dot (no count) state is asserted
*/
async assertUnread(room: string | { name: string }, count: number | ".") {
const tile = this.getRoomListTile(room);
if (count === ".") {
await expect(tile.locator(".mx_NotificationBadge_dot")).toBeVisible();
} else {
await expect(tile.locator(".mx_NotificationBadge_count")).toHaveText(count.toString());
}
}
/**
* Assert a given room is marked as unread, and the number of unread
* messages is less than the supplied count.
*
* @param room - the name of the room to check
* @param lessThan - the number of unread messages that is too many
*/
async assertUnreadLessThan(room: string | { name: string }, lessThan: number) {
const tile = this.getRoomListTile(room);
// https://playwright.dev/docs/test-assertions#expectpoll
// .toBeLessThan doesn't have a retry mechanism, so we use .poll
await expect
.poll(async () => {
return parseInt(await tile.locator(".mx_NotificationBadge_count").textContent(), 10);
})
.toBeLessThan(lessThan);
}
/**
* Assert a given room is marked as unread, and the number of unread
* messages is greater than the supplied count.
*
* @param room - the name of the room to check
* @param greaterThan - the number of unread messages that is too few
*/
async assertUnreadGreaterThan(room: string | { name: string }, greaterThan: number) {
const tile = this.getRoomListTile(room);
// https://playwright.dev/docs/test-assertions#expectpoll
// .toBeGreaterThan doesn't have a retry mechanism, so we use .poll
await expect
.poll(async () => {
return parseInt(await tile.locator(".mx_NotificationBadge_count").textContent(), 10);
})
.toBeGreaterThan(greaterThan);
}
/**
* Click the "Threads" or "Back" button if needed to get to the threads list.
*/
async openThreadList() {
// If we've just entered the room, the threads panel takes a while to decide
// whether it's open or not - wait here to give it a chance to settle.
await this.page.waitForTimeout(200);
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>('[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) {
$button.click();
}
});
}
async findRoomByName(roomName: string): Promise<JSHandle<Room>> {
return this.app.client.evaluateHandle((cli, roomName) => {
return cli.getRooms().find((r) => r.name === roomName);
}, roomName);
}
private async getThreadListTile(rootMessage: string) {
await this.openThreadList();
return this.page.locator(".mx_ThreadPanel li", { hasText: rootMessage });
}
/**
* Assert that the thread with the supplied content in its root message is shown
* as read in the Threads list.
*/
async assertReadThread(rootMessage: string) {
const tile = await this.getThreadListTile(rootMessage);
await expect(tile.locator(".mx_NotificationBadge")).not.toBeVisible();
}
/**
* Assert that the thread with the supplied content in its root message is shown
* as unread in the Threads list.
*/
async assertUnreadThread(rootMessage: string) {
const tile = await this.getThreadListTile(rootMessage);
await expect(tile.locator(".mx_NotificationBadge")).toBeVisible();
}
/**
* Save our indexeddb information and then refresh the page.
*/
async saveAndReload() {
await this.app.client.evaluate((cli) => {
// @ts-ignore
return (cli.store as IndexedDBStore).reallySave();
});
await this.page.reload();
// Wait for the app to reload
await expect(this.page.locator(".mx_RoomView")).toBeVisible();
}
/**
* Sends messages into given room as a bot
* @param room - the name of the room to send messages into
* @param messages - the list of messages to send, these can be strings or implementations of MessageSpec like `editOf`
*/
async receiveMessages(room: string | { name: string }, messages: Message[]) {
await this.sendMessageAsClient(this.bot, room, messages);
}
/**
* Open the room list menu
*/
async toggleRoomListMenu() {
const tile = this.getRoomListTile("Rooms");
await tile.hover();
const button = tile.getByLabel("List options");
await button.click();
}
/**
* Toggle the `Show rooms with unread messages first` option for the room list
*/
async toggleRoomUnreadOrder() {
await this.toggleRoomListMenu();
await this.page.getByText("Show rooms with unread messages first").click();
// Close contextual menu
await this.page.locator(".mx_ContextualMenu_background").click();
}
/**
* Assert that the room list is ordered as expected
* @param rooms
*/
async assertRoomListOrder(rooms: Array<{ name: string }>) {
const roomList = this.page.locator(".mx_RoomTile_title");
for (const [i, room] of rooms.entries()) {
await expect(roomList.nth(i)).toHaveText(room.name);
}
}
}
/**
* BotActionSpec to send a custom event
* @param eventType - the type of the event to send
* @param content - the event content to send
*/
export function customEvent(eventType: string, content: Record<string, any>): BotActionSpec {
return new (class extends BotActionSpec {
public async performAction(cli: Client, room: JSHandle<Room>): Promise<void> {
const roomId = await room.evaluate((room) => room.roomId);
await cli.sendEvent(roomId, null, eventType, content);
}
})();
}
/**
* Generate strings with the supplied prefix, suffixed with numbers.
*
* @param prefix the prefix of each string
* @param howMany the number of strings to generate
*/
export function many(prefix: string, howMany: number): Array<string> {
return Array.from(Array(howMany).keys()).map((i) => prefix + i.toString().padStart(4, "0"));
}
export { expect };

View file

@ -0,0 +1,84 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* 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

@ -0,0 +1,51 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* See readme.md for tips on writing these tests. */
import { test } from ".";
test.describe("Read receipts", () => {
test.describe("messages with missing referents", () => {
test.fixme(
"A message in an unknown thread is not visible and the room is read",
async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => {
// Given a thread existed and the room is read
await util.goTo(room1);
await util.receiveMessages(room2, ["Root1", msg.threadedOff("Root1", "T1a")]);
// When I restart, forgetting the thread root
// And I receive a message on that thread
// Then the message is invisible and the room remains read
},
);
test.fixme("When a message's thread root appears later the thread appears and the room is unread", () => {});
test.fixme("An edit of an unknown message is not visible and the room is read", () => {});
test.fixme("When an edit's message appears later the edited version appears and the room is unread", () => {});
test.fixme("A reaction to an unknown message is not visible and the room is read", () => {});
test.fixme("When an reactions's message appears later it appears and the room is unread", () => {});
// Harder: validate that we request the messages we are missing?
});
test.describe("receipts with missing events", () => {
// Later: when we have order in receipts, we can change these tests to
// make receipts still work, even when their message is not found.
test.fixme("A receipt for an unknown message does not change the state of an unread room", () => {});
test.fixme("A receipt for an unknown message does not change the state of a read room", () => {});
test.fixme("A threaded receipt for an unknown message does not change the state of an unread thread", () => {});
test.fixme("A threaded receipt for an unknown message does not change the state of a read thread", () => {});
test.fixme("A threaded receipt for an unknown thread does not change the state of an unread thread", () => {});
test.fixme("A threaded receipt for an unknown thread does not change the state of a read thread", () => {});
test.fixme("A threaded receipt for a message on main does not change the state of an unread room", () => {});
test.fixme("A threaded receipt for a message on main does not change the state of a read room", () => {});
test.fixme("A main receipt for a message on a thread does not change the state of an unread room", () => {});
test.fixme("A main receipt for a message on a thread does not change the state of a read room", () => {});
test.fixme("A threaded receipt for a thread root does not mark it as read", () => {});
// Harder: validate that we request the messages we are missing?
});
});

View file

@ -0,0 +1,301 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* See readme.md for tips on writing these tests. */
import { many, test } from ".";
test.describe("Read receipts", () => {
test.describe("new messages", () => {
test.describe("in threads", () => {
test("Receiving a message makes a room unread", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a message arrived and is read
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 I receive a threaded message
await util.receiveMessages(room2, [msg.threadedOff("Msg1", "Resp1")]);
// Then the room stays read
await util.assertRead(room2);
// but the thread is unread
await util.goTo(room2);
await util.assertUnreadThread("Msg1");
});
test("Reading the last threaded message makes the room read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a thread exists and is not read
await util.goTo(room1);
await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
await util.assertUnread(room2, 1);
await util.goTo(room2);
// When I read it
await util.openThread("Msg1");
// The thread becomes read
await util.assertReadThread("Msg1");
});
test("Reading a thread message makes the thread 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"),
msg.threadedOff("Msg1", "Resp2"),
]);
await util.assertUnread(room2, 1); // (Sanity)
// When I read the main timeline
await util.goTo(room2);
// Then room is read
await util.assertRead(room2);
// Reading the thread causes it to become read too
await util.openThread("Msg1");
await util.assertReadThread("Msg1");
await util.assertRead(room2);
});
test("Reading an older thread message leaves the thread unread", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given there are many messages in a thread
await util.goTo(room1);
await util.receiveMessages(room2, [
"ThreadRoot",
...msg.manyThreadedOff("ThreadRoot", many("InThread", 20)),
]);
await util.assertUnread(room2, 1);
await util.goTo(room2);
await util.assertUnreadThread("ThreadRoot");
await util.goTo(room1);
// When I read an older message in the thread
await msg.jumpTo(room2.name, "InThread0000", true);
// Then the thread is still marked as unread
await util.backToThreadsList();
await util.assertUnreadThread("ThreadRoot");
});
test("Reading only one thread's message makes that thread read but not others", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given I have unread threads
await util.goTo(room1);
await util.receiveMessages(room2, [
"Msg1",
"Msg2",
msg.threadedOff("Msg1", "Resp1"),
msg.threadedOff("Msg2", "Resp2"),
]);
await util.assertUnread(room2, 2); // (Sanity)
await util.goTo(room2);
await util.assertRead(room2);
await util.assertUnreadThread("Msg1");
await util.assertUnreadThread("Msg2");
// When I read one of them
await util.openThread("Msg1");
// Then that one is read, but the other is not
await util.assertReadThread("Msg1");
await util.assertUnreadThread("Msg2");
});
test("Reading the main timeline does not mark a thread message 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"),
msg.threadedOff("Msg1", "Resp2"),
]);
await util.assertUnread(room2, 1); // (Sanity)
// When I read the main timeline
await util.goTo(room2);
await util.assertRead(room2);
// Then thread does appear unread
await util.assertUnreadThread("Msg1");
});
test("Marking a room with unread threads as read makes it read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given I have an unread thread
await util.goTo(room1);
await util.receiveMessages(room2, [
"Msg1",
msg.threadedOff("Msg1", "Resp1"),
msg.threadedOff("Msg1", "Resp2"),
]);
await util.assertUnread(room2, 1); // (Sanity)
// When I mark the room as read
await util.markAsRead(room2);
// Then the room is read
await util.assertRead(room2);
// and so are the threads
await util.assertReadThread("Msg1");
});
test("Sending a new thread message after marking as read makes it unread", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a thread exists
await util.goTo(room1);
await util.receiveMessages(room2, [
"Msg1",
msg.threadedOff("Msg1", "Resp1"),
msg.threadedOff("Msg1", "Resp2"),
]);
// When I mark the room as read
await util.markAsRead(room2);
await util.assertRead(room2);
// Then another message appears in the thread
await util.receiveMessages(room2, [msg.threadedOff("Msg1", "Resp3")]);
// Then the thread becomes unread
await util.goTo(room2);
await util.assertUnreadThread("Msg1");
});
test("Sending a new different-thread message after marking as read makes it unread", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given 2 threads exist, and Thread2 has the latest message in it
await util.goTo(room1);
await util.receiveMessages(room2, ["Thread1", "Thread2", msg.threadedOff("Thread1", "t1a")]);
// Make sure the message in Thread 1 has definitely arrived, so that we know for sure
// that the one in Thread 2 is the latest.
await util.receiveMessages(room2, [msg.threadedOff("Thread2", "t2a")]);
// When I mark the room as read (making an unthreaded receipt for t2a)
await util.markAsRead(room2);
await util.assertRead(room2);
// Then another message appears in the other thread
await util.receiveMessages(room2, [msg.threadedOff("Thread1", "t1b")]);
// Then the other thread becomes unread
await util.goTo(room2);
await util.assertUnreadThread("Thread1");
});
test("A room with a new threaded message is still unread after restart", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a thread exists
await util.goTo(room1);
await util.receiveMessages(room2, [
"Msg1",
msg.threadedOff("Msg1", "Resp1"),
msg.threadedOff("Msg1", "Resp2"),
]);
await util.assertUnread(room2, 1); // (Sanity)
// When I read the main timeline
await util.goTo(room2);
// Then room appears read
await util.assertRead(room2);
/// but with an unread thread
await util.assertUnreadThread("Msg1");
await util.saveAndReload();
await util.assertRead(room2);
await util.goTo(room2);
await util.assertUnreadThread("Msg1");
// Opening the thread now marks it as read
await util.openThread("Msg1");
await util.assertReadThread("Msg1");
});
test("A room where all threaded messages are read is still read after restart", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given I have read all the threads
await util.goTo(room1);
await util.receiveMessages(room2, [
"Msg1",
msg.threadedOff("Msg1", "Resp1"),
msg.threadedOff("Msg1", "Resp2"),
]);
await util.assertUnread(room2, 1); // (Sanity)
await util.goTo(room2);
await util.assertRead(room2);
await util.assertUnreadThread("Msg1");
await util.openThread("Msg1");
await util.assertReadThread("Msg1");
// When I restart
await util.saveAndReload();
// Then the room & thread still read
await util.assertRead(room2);
await util.goTo(room2);
await util.assertReadThread("Msg1");
});
});
});
});

View file

@ -0,0 +1,160 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* 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,110 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* 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,48 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* 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

@ -0,0 +1,201 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* See readme.md for tips on writing these tests. */
import { test, expect } from ".";
test.describe("Read receipts", () => {
test.describe("reactions", () => {
test.describe("in threads", () => {
test("A reaction to a threaded message does not make the room unread", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a thread exists and I have read it
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");
await util.goTo(room1);
// When someone reacts to a thread message
await util.receiveMessages(room2, [msg.reactionTo("Reply1", "🪿")]);
// Then the room remains read
await util.assertStillRead(room2);
await util.assertReadThread("Msg1");
});
test("Marking a room as read after a reaction in a thread makes it read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a thread exists with a reaction
await util.goTo(room1);
await util.assertRead(room2);
await util.receiveMessages(room2, [
"Msg1",
msg.threadedOff("Msg1", "Reply1"),
msg.reactionTo("Reply1", "🪿"),
]);
await util.assertUnread(room2, 1);
// When I mark the room as read
await util.markAsRead(room2);
// Then it becomes read
await util.assertRead(room2);
});
test("Reacting to a thread message after marking as read does not make the room unread", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a thread exists and I have marked it as read
await util.goTo(room1);
await util.assertRead(room2);
await util.receiveMessages(room2, [
"Msg1",
msg.threadedOff("Msg1", "Reply1"),
msg.reactionTo("Reply1", "🪿"),
]);
await util.assertUnread(room2, 1);
await util.markAsRead(room2);
await util.assertRead(room2);
// When someone reacts to a thread message
await util.receiveMessages(room2, [msg.reactionTo("Reply1", "🪿")]);
// Then the room remains read
await util.assertStillRead(room2);
// as does the thread
await util.assertReadThread("Msg1");
});
test("A room with a reaction to a threaded message is still unread after restart", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a thread exists and I have read it
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.goTo(room1);
// And someone reacted to it, which doesn't make it read
await util.receiveMessages(room2, [msg.reactionTo("Reply1", "🪿")]);
await util.assertStillRead(room2);
await util.assertReadThread("Msg1");
// When I restart
await util.saveAndReload();
// Then the room is still read
await util.assertRead(room2);
await util.assertReadThread("Msg1");
});
test("A room where all reactions in threads are read is still read after restart", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given multiple threads with reactions exist and are read
await util.goTo(room1);
await util.assertRead(room2);
await util.receiveMessages(room2, [
"Msg1",
msg.threadedOff("Msg1", "Reply1a"),
msg.reactionTo("Reply1a", "r"),
"Msg2",
msg.threadedOff("Msg1", "Reply1b"),
msg.threadedOff("Msg2", "Reply2a"),
msg.reactionTo("Msg1", "e"),
msg.threadedOff("Msg2", "Reply2b"),
msg.reactionTo("Reply2a", "a"),
msg.reactionTo("Reply2b", "c"),
msg.reactionTo("Reply1b", "t"),
]);
await util.assertUnread(room2, 2);
await util.goTo(room2);
await util.openThread("Msg1");
await util.assertReadThread("Msg1");
await util.openThread("Msg2");
await util.assertReadThread("Msg2");
await util.assertRead(room2);
await util.goTo(room1);
// When I restart
await util.saveAndReload();
// Then the room is still read
await util.assertRead(room2);
await util.goTo(room2);
await util.assertReadThread("Msg1");
await util.assertReadThread("Msg2");
});
test("Can remove a reaction in a thread", async ({
page,
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Note: this is not strictly a read receipt test, but it checks
// for a bug we caused when we were fixing unreads, so it's
// included here. The bug is:
// https://github.com/vector-im/element-web/issues/26498
// Given a thread exists
await util.goTo(room1);
await util.assertRead(room2);
await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1a")]);
await util.assertUnread(room2, 1);
// When I react to a thread message
await util.goTo(room2);
await util.openThread("Msg1");
await page.locator(".mx_ThreadPanel").getByText("Reply1a").hover();
await page.getByRole("button", { name: "React" }).click();
await page.locator(".mx_EmojiPicker_body").getByText("😀").click();
// And cancel the reaction
await page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀").click();
// Then it disappears
await expect(page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀")).not.toBeVisible();
// And I can do it all again without an error
await page.locator(".mx_ThreadPanel").getByText("Reply1a").hover();
await page.getByRole("button", { name: "React" }).click();
await page.locator(".mx_EmojiPicker_body").getByText("😀").first().click();
await page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀").click();
await expect(await page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀")).not.toBeVisible();
});
});
});
});

View file

@ -0,0 +1,91 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* 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,107 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* 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

@ -0,0 +1,337 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import type { JSHandle } from "@playwright/test";
import type { MatrixEvent, ISendEventResponse, ReceiptType } from "matrix-js-sdk/src/matrix";
import { expect } from "../../element-web-test";
import { ElementAppPage } from "../../pages/ElementAppPage";
import { Bot } from "../../pages/bot";
import { test } from ".";
test.describe("Read receipts", () => {
test.use({
displayName: "Mae",
botCreateOpts: { displayName: "Other User" },
});
const selectedRoomName = "Selected Room";
const otherRoomName = "Other Room";
let otherRoomId: string;
let selectedRoomId: string;
const sendMessage = async (bot: Bot, no = 1): Promise<ISendEventResponse> => {
return bot.sendMessage(otherRoomId, { body: `Message ${no}`, msgtype: "m.text" });
};
const botSendThreadMessage = (bot: Bot, threadId: string): Promise<ISendEventResponse> => {
return bot.sendEvent(otherRoomId, threadId, "m.room.message", { body: "Message", msgtype: "m.text" });
};
const fakeEventFromSent = (
app: ElementAppPage,
eventResponse: ISendEventResponse,
threadRootId: string | undefined,
): Promise<JSHandle<MatrixEvent>> => {
return app.client.evaluateHandle(
(client, { otherRoomId, eventResponse, threadRootId }) => {
return {
getRoomId: () => otherRoomId,
getId: () => eventResponse.event_id,
threadRootId,
getTs: () => 1,
isRelation: (relType) => {
return !relType || relType === "m.thread";
},
} as any as MatrixEvent;
},
{ otherRoomId, eventResponse, threadRootId },
);
};
/**
* Send a threaded receipt marking the message referred to in
* eventResponse as read. If threadRootEventResponse is supplied, the
* receipt will have its event_id as the thread root ID for the receipt.
*/
const sendThreadedReadReceipt = async (
app: ElementAppPage,
eventResponse: ISendEventResponse,
threadRootEventResponse: ISendEventResponse = undefined,
) => {
await app.client.sendReadReceipt(
await fakeEventFromSent(app, eventResponse, threadRootEventResponse?.event_id),
);
};
/**
* Send an unthreaded receipt marking the message referred to in
* eventResponse as read.
*/
const sendUnthreadedReadReceipt = async (app: ElementAppPage, eventResponse: ISendEventResponse) => {
await app.client.sendReadReceipt(
await fakeEventFromSent(app, eventResponse, undefined),
"m.read" as any as ReceiptType,
true,
);
};
test.beforeEach(async ({ page, app, user, bot }) => {
/*
* Create 2 rooms:
*
* - Selected room - this one is clicked in the UI
* - Other room - this one contains the bot, which will send events so
* we can check its unread state.
*/
selectedRoomId = await app.client.createRoom({ name: selectedRoomName });
// Invite the bot to Other room
otherRoomId = await app.client.createRoom({ name: otherRoomName, invite: [bot.credentials.userId] });
await page.goto(`/#/room/${otherRoomId}`);
await expect(page.getByText(`${bot.credentials.displayName} joined the room`)).toBeVisible();
// Then go into Selected room
await page.goto(`/#/room/${selectedRoomId}`);
});
// Disabled due to flakiness: https://github.com/element-hq/element-web/issues/26895
test.skip("With sync accumulator, considers main thread and unthreaded receipts #24629", async ({
page,
app,
bot,
}) => {
// Details are in https://github.com/vector-im/element-web/issues/24629
// This proves we've fixed one of the "stuck unreads" issues.
// Given we sent 3 events on the main thread
await sendMessage(bot);
const main2 = await sendMessage(bot);
const main3 = await sendMessage(bot);
// (So the room starts off unread)
await expect(page.getByLabel(`${otherRoomName} 3 unread messages.`)).toBeVisible();
// When we send a threaded receipt for the last event in main
// And an unthreaded receipt for an earlier event
await sendThreadedReadReceipt(app, main3);
await sendUnthreadedReadReceipt(app, main2);
// (So the room has no unreads)
await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible();
// And we persuade the app to persist its state to indexeddb by reloading and waiting
await page.reload();
await expect(page.getByLabel(`${selectedRoomName}`)).toBeVisible();
// And we reload again, fetching the persisted state FROM indexeddb
await page.reload();
// Then the room is read, because the persisted state correctly remembers both
// receipts. (In #24629, the unthreaded receipt overwrote the main thread one,
// meaning that the room still said it had unread messages.)
await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible();
await expect(page.getByLabel(`${otherRoomName} Unread messages.`)).not.toBeVisible();
});
test("Recognises unread messages on main thread after receiving a receipt for earlier ones", async ({
page,
app,
bot,
}) => {
// Given we sent 3 events on the main thread
await sendMessage(bot);
const main2 = await sendMessage(bot);
await sendMessage(bot);
// (The room starts off unread)
await expect(page.getByLabel(`${otherRoomName} 3 unread messages.`)).toBeVisible();
// When we send a threaded receipt for the second-last event in main
await sendThreadedReadReceipt(app, main2);
// Then the room has only one unread
await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible();
});
test("Considers room read if there is only a main thread and we have a main receipt", async ({
page,
app,
bot,
}) => {
// Given we sent 3 events on the main thread
await sendMessage(bot);
await sendMessage(bot);
const main3 = await sendMessage(bot);
// (The room starts off unread)
await expect(page.getByLabel(`${otherRoomName} 3 unread messages.`)).toBeVisible();
// When we send a threaded receipt for the last event in main
await sendThreadedReadReceipt(app, main3);
// Then the room has no unreads
await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible();
});
test("Recognises unread messages on other thread after receiving a receipt for earlier ones", async ({
page,
app,
bot,
util,
}) => {
// Given we sent 3 events on the main thread
const main1 = await sendMessage(bot);
const thread1a = await botSendThreadMessage(bot, main1.event_id);
await botSendThreadMessage(bot, main1.event_id);
// 1 unread on the main thread, 2 in the new thread that aren't shown
await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible();
// When we send receipts for main, and the second-last in the thread
await sendThreadedReadReceipt(app, main1);
await sendThreadedReadReceipt(app, thread1a, main1);
// Then the room has only one unread - the one in the thread
await util.goTo(otherRoomName);
await util.assertUnreadThread("Message 1");
});
test("Considers room read if there are receipts for main and other thread", async ({ page, app, bot, util }) => {
// Given we sent 3 events on the main thread
const main1 = await sendMessage(bot);
await botSendThreadMessage(bot, main1.event_id);
const thread1b = await botSendThreadMessage(bot, main1.event_id);
// 1 unread on the main thread, 2 in the new thread which don't show
await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible();
// When we send receipts for main, and the last in the thread
await sendThreadedReadReceipt(app, main1);
await sendThreadedReadReceipt(app, thread1b, main1);
// Then the room has no unreads
await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible();
await util.goTo(otherRoomName);
await util.assertReadThread("Message 1");
});
test("Recognises unread messages on a thread after receiving a unthreaded receipt for earlier ones", async ({
page,
app,
bot,
util,
}) => {
// Given we sent 3 events on the main thread
const main1 = await sendMessage(bot);
const thread1a = await botSendThreadMessage(bot, main1.event_id);
await botSendThreadMessage(bot, main1.event_id);
// 1 unread on the main thread, 2 in the new thread which don't count
await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible();
// When we send an unthreaded receipt for the second-last in the thread
await sendUnthreadedReadReceipt(app, thread1a);
// Then the room has only one unread - the one in the
// thread. The one in main is read because the unthreaded
// receipt is for a later event. The room should therefore be
// read, and the thread unread.
await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible();
await util.goTo(otherRoomName);
await util.assertUnreadThread("Message 1");
});
test("Recognises unread messages on main after receiving a unthreaded receipt for a thread message", async ({
page,
app,
bot,
}) => {
// Given we sent 3 events on the main thread
const main1 = await sendMessage(bot);
await botSendThreadMessage(bot, main1.event_id);
const thread1b = await botSendThreadMessage(bot, main1.event_id);
await sendMessage(bot);
// 2 unreads on the main thread, 2 in the new thread which don't count
await expect(page.getByLabel(`${otherRoomName} 2 unread messages.`)).toBeVisible();
// When we send an unthreaded receipt for the last in the thread
await sendUnthreadedReadReceipt(app, thread1b);
// Then the room has only one unread - the one in the
// main thread, because it is later than the unthreaded
// receipt.
await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible();
});
/**
* The idea of this test is to intercept the receipt / read read_markers requests and
* assert that the correct ones are sent.
* Prose playbook:
* - Another user sends enough messages that the timeline becomes scrollable
* - The current user looks at the room and jumps directly to the first unread message
* - At this point, a receipt for the last message in the room and
* a fully read marker for the last visible message are expected to be sent
* - Then the user jumps to the end of the timeline
* - A fully read marker for the last message in the room is expected to be sent
*/
test("Should send the correct receipts", async ({ page, bot }) => {
const uriEncodedOtherRoomId = encodeURIComponent(otherRoomId);
const receiptRequestPromise = page.waitForRequest(
new RegExp(`http://localhost:\\d+/_matrix/client/v3/rooms/${uriEncodedOtherRoomId}/receipt/m\\.read/.+`),
);
const numberOfMessages = 20;
const sendMessageResponses: ISendEventResponse[] = [];
for (let i = 1; i <= numberOfMessages; i++) {
sendMessageResponses.push(await sendMessage(bot, i));
}
const lastMessageId = sendMessageResponses.at(-1).event_id;
const uriEncodedLastMessageId = encodeURIComponent(lastMessageId);
// wait until all messages have been received
await expect(page.getByLabel(`${otherRoomName} ${sendMessageResponses.length} unread messages.`)).toBeVisible();
// switch to the room with the messages
await page.goto(`/#/room/${otherRoomId}`);
const receiptRequest = await receiptRequestPromise;
// assert the read receipt for the last message in the room
expect(receiptRequest.url()).toContain(uriEncodedLastMessageId);
expect(receiptRequest.postDataJSON()).toEqual({
thread_id: "main",
});
// the following code tests the fully read marker somewhere in the middle of the room
const readMarkersRequestPromise = page.waitForRequest(
new RegExp(`http://localhost:\\d+/_matrix/client/v3/rooms/${uriEncodedOtherRoomId}/read_markers`),
);
await page.getByRole("button", { name: "Jump to first unread message." }).click();
const readMarkersRequest = await readMarkersRequestPromise;
// since this is not pixel perfect,
// the fully read marker should be +/- 1 around the last visible message
expect([
sendMessageResponses[11].event_id,
sendMessageResponses[12].event_id,
sendMessageResponses[13].event_id,
]).toContain(readMarkersRequest.postDataJSON()["m.fully_read"]);
// the following code tests the fully read marker at the bottom of the room
const readMarkersRequestPromise2 = page.waitForRequest(
new RegExp(`http://localhost:\\d+/_matrix/client/v3/rooms/${uriEncodedOtherRoomId}/read_markers`),
);
await page.getByRole("button", { name: "Scroll to most recent messages" }).click();
const readMarkersRequest2 = await readMarkersRequestPromise2;
expect(readMarkersRequest2.postDataJSON()).toEqual({
["m.fully_read"]: sendMessageResponses.at(-1).event_id,
});
});
});

View file

@ -0,0 +1,20 @@
# High Level Read Receipt Tests
Tips for writing these tests:
- Break up your tests into the smallest test case possible. The purpose of
these tests is to understand hard-to-find bugs, so small tests are necessary.
We know that Playwright recommends combining tests together for performance, but
that will frustrate our goals here. (We will need to find a different way to
reduce CI time.)
- Try to assert something after every action, to make sure it has completed.
E.g.:
markAsRead(room2);
assertRead(room2);
You should especially follow this rule if you are jumping to a different
room or similar straight afterward.
- Use assertStillRead() if you are asserting something is read when it was
also read before. This waits a little while to make sure you're not getting a
false positive.

View file

@ -0,0 +1,554 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* See readme.md for tips on writing these tests. */
import { test } from ".";
test.describe("Read receipts", () => {
test.describe("redactions", () => {
test.describe("in threads", () => {
test("Redacting the threaded message pointed to by my receipt leaves the room read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given I have some threads
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root1",
msg.threadedOff("Root1", "ThreadMsg1"),
msg.threadedOff("Root1", "ThreadMsg2"),
"Root2",
msg.threadedOff("Root2", "Root2->A"),
]);
await util.assertUnread(room2, 2);
await util.goTo(room2);
await util.assertUnreadThread("Root1");
await util.assertUnreadThread("Root2");
// And I have read them
await util.assertUnreadThread("Root1");
await util.openThread("Root1");
await util.assertRead(room2);
await util.backToThreadsList();
await util.assertReadThread("Root1");
await util.openThread("Root2");
await util.assertReadThread("Root2");
await util.closeThreadsPanel();
await util.goTo(room1);
// When the latest message in a thread is redacted
await util.receiveMessages(room2, [msg.redactionOf("ThreadMsg2")]);
// Then the room and thread are still read
await util.assertStillRead(room2);
await util.goTo(room2);
await util.assertReadThread("Root1");
});
test("Reading an unread thread after a redaction of the latest message makes it read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given an unread thread where the latest message was redacted
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root",
msg.threadedOff("Root", "ThreadMsg1"),
msg.threadedOff("Root", "ThreadMsg2"),
]);
await util.assertUnread(room2, 1);
await util.receiveMessages(room2, [msg.redactionOf("ThreadMsg2")]);
await util.assertUnread(room2, 1);
await util.goTo(room2);
await util.assertUnreadThread("Root");
// When I read the thread
await util.openThread("Root");
await util.assertRead(room2);
await util.closeThreadsPanel();
await util.goTo(room1);
// Then the thread is read
await util.assertRead(room2);
await util.goTo(room2);
await util.assertReadThread("Root");
});
test("Reading an unread thread after a redaction of the latest message makes it read after restart", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a redacted message is not counted in the unread count
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root",
msg.threadedOff("Root", "ThreadMsg1"),
msg.threadedOff("Root", "ThreadMsg2"),
]);
await util.assertUnread(room2, 1);
await util.receiveMessages(room2, [msg.redactionOf("ThreadMsg2")]);
await util.assertUnread(room2, 1);
await util.goTo(room2);
await util.assertUnreadThread("Root");
await util.openThread("Root");
await util.assertRead(room2);
await util.closeThreadsPanel();
await util.goTo(room1);
await util.assertRead(room2);
await util.goTo(room2);
await util.assertReadThread("Root");
// When I restart
await util.saveAndReload();
// Then the room and thread are still read
await util.assertRead(room2);
await util.openThreadList();
await util.assertReadThread("Root");
});
test("Reading an unread thread after a redaction of an older message makes it read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given an unread thread where an older message was redacted
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root",
msg.threadedOff("Root", "ThreadMsg1"),
msg.threadedOff("Root", "ThreadMsg2"),
]);
await util.assertUnread(room2, 1);
await util.receiveMessages(room2, [msg.redactionOf("ThreadMsg1")]);
await util.assertUnread(room2, 1);
await util.goTo(room2);
await util.assertUnreadThread("Root");
// When I read the thread
await util.openThread("Root");
await util.assertRead(room2);
await util.closeThreadsPanel();
await util.goTo(room1);
// Then the thread is read
await util.assertRead(room2);
await util.goTo(room2);
await util.assertReadThread("Root");
});
test("Marking an unread thread as read after a redaction makes it read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given an unread thread where an older message was redacted
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root",
msg.threadedOff("Root", "ThreadMsg1"),
msg.threadedOff("Root", "ThreadMsg2"),
]);
await util.assertUnread(room2, 1);
await util.receiveMessages(room2, [msg.redactionOf("ThreadMsg1")]);
await util.assertUnread(room2, 1);
// When I mark the room as read
await util.markAsRead(room2);
await util.assertRead(room2);
// Then the thread is read
await util.assertRead(room2);
await util.goTo(room2);
await util.assertReadThread("Root");
});
test("Sending and redacting a message after marking the thread as read leaves it read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a thread exists and is marked as read
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root",
msg.threadedOff("Root", "ThreadMsg1"),
msg.threadedOff("Root", "ThreadMsg2"),
]);
await util.assertUnread(room2, 1);
await util.markAsRead(room2);
await util.assertRead(room2);
// When I send and redact a message
await util.receiveMessages(room2, [msg.threadedOff("Root", "Msg3")]);
await util.goTo(room2);
await util.openThreadList();
await util.assertUnreadThread("Root");
await util.receiveMessages(room2, [msg.redactionOf("Msg3")]);
// Then the room and thread are read
await util.goTo(room2);
await util.assertReadThread("Root");
});
test("Redacting a message after marking the thread as read leaves it read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a thread exists and is marked as read
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root",
msg.threadedOff("Root", "ThreadMsg1"),
msg.threadedOff("Root", "ThreadMsg2"),
]);
await util.assertUnread(room2, 1);
await util.markAsRead(room2);
await util.assertRead(room2);
// When I redact a message
await util.receiveMessages(room2, [msg.redactionOf("ThreadMsg1")]);
// Then the room and thread are read
await util.assertRead(room2);
await util.goTo(room2);
await util.assertReadThread("Root");
});
test("Reacting to a redacted message leaves the thread read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a message in a thread was redacted and everything 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.receiveMessages(room2, [msg.redactionOf("Msg2")]);
await util.assertUnread(room2, 1);
await util.goTo(room2);
await util.assertRead(room2);
await util.openThread("Root");
await util.assertRead(room2);
await util.backToThreadsList();
await util.assertReadThread("Root");
await util.goTo(room1);
// When we receive a reaction to the redacted event
await util.receiveMessages(room2, [msg.reactionTo("Msg2", "z")]);
// Then the room is read
await util.assertStillRead(room2);
await util.goTo(room2);
await util.openThreadList();
await util.assertReadThread("Root");
});
test("Editing a redacted message leaves the thread read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a message in a thread was redacted and everything 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.receiveMessages(room2, [msg.redactionOf("Msg2")]);
await util.assertUnread(room2, 1);
await util.goTo(room2);
await util.assertRead(room2);
await util.openThreadList();
await util.assertUnreadThread("Root");
await util.openThread("Root");
await util.assertReadThread("Root");
await util.goTo(room1);
// When we receive an edit of the redacted message
await util.receiveMessages(room2, [msg.editOf("Msg2", "New Msg2")]);
// Then the room is unread
await util.assertStillRead(room2);
// and so is the thread
await util.goTo(room2);
await util.openThreadList();
await util.assertReadThread("Root");
});
test("Reading a thread after a reaction to a redacted message marks the thread as read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a redacted message in a thread exists, but someone reacted to it before it was redacted
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root",
msg.threadedOff("Root", "Msg2"),
msg.threadedOff("Root", "Msg3"),
msg.reactionTo("Msg3", "x"),
]);
await util.assertUnread(room2, 1);
await util.receiveMessages(room2, [msg.redactionOf("Msg3")]);
await util.assertUnread(room2, 1);
// When we read the thread
await util.goTo(room2);
await util.openThread("Root");
// Then the thread (and room) are read
await util.assertRead(room2);
await util.assertReadThread("Root");
});
test("Reading a thread containing a redacted, edited message marks the thread as read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a redacted message in a thread exists, but someone edited it before it was redacted
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root",
msg.threadedOff("Root", "Msg2"),
msg.threadedOff("Root", "Msg3"),
msg.editOf("Msg3", "Msg3 Edited"),
]);
await util.assertUnread(room2, 1);
await util.receiveMessages(room2, [msg.redactionOf("Msg3")]);
// When we read the thread
await util.goTo(room2);
await util.openThread("Root");
// Then the thread (and room) are read
await util.assertRead(room2);
await util.assertReadThread("Root");
});
test("Reading a reply to a redacted message marks the thread as read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a redacted message in a thread exists, but someone replied before it was redacted
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root",
msg.threadedOff("Root", "Msg2"),
msg.threadedOff("Root", "Msg3"),
msg.replyTo("Msg3", "Msg3Reply"),
]);
await util.assertUnread(room2, 1);
await util.receiveMessages(room2, [msg.redactionOf("Msg3")]);
// When we read the thread, creating a receipt that points at the edit
await util.goTo(room2);
await util.openThread("Root");
// Then the thread (and room) are read
await util.assertRead(room2);
await util.assertReadThread("Root");
});
test("Reading a thread root when its only message has been redacted leaves the room read", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given we had a thread
await util.goTo(room1);
await util.receiveMessages(room2, ["Root", msg.threadedOff("Root", "Msg2")]);
await util.assertUnread(room2, 1);
// And then redacted the message that makes it a thread
await util.receiveMessages(room2, [msg.redactionOf("Msg2")]);
await util.assertUnread(room2, 1);
// When we read the main timeline
await util.goTo(room2);
// Then the room is read
await util.assertRead(room2);
// and that thread is read
await util.openThreadList();
await util.assertReadThread("Root");
});
test("A thread with a redacted unread is still read after restart", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given I sent and redacted a message in an otherwise-read thread
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root",
msg.threadedOff("Root", "ThreadMsg1"),
msg.threadedOff("Root", "ThreadMsg2"),
]);
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.threadedOff("Root", "Msg3")]);
await util.assertRead(room2);
await util.receiveMessages(room2, [msg.redactionOf("Msg3")]);
await util.assertRead(room2);
await util.goTo(room2);
await util.assertReadThread("Root");
await util.goTo(room1);
// When I restart
await util.saveAndReload();
// Then the room and thread are still read
await util.assertRead(room2);
await util.goTo(room2);
await util.assertReadThread("Root");
});
/*
* Disabled: this doesn't seem to work as, at some point after syncing from cache, the redaction and redacted
* event get removed from the thread timeline such that we have no record of the events that the read receipt
* points to. I suspect this may have been passing by fluke before.
*/
test.skip("A thread with a read redaction is still read after restart", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given my receipt points at a redacted thread message
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root1",
msg.threadedOff("Root1", "ThreadMsg1"),
msg.threadedOff("Root1", "ThreadMsg2"),
"Root2",
msg.threadedOff("Root2", "Root2->A"),
]);
await util.assertUnread(room2, 2);
await util.goTo(room2);
await util.assertUnreadThread("Root1");
await util.openThread("Root1");
await util.assertRead(room2);
await util.openThread("Root2");
await util.assertRead(room2);
await util.closeThreadsPanel();
await util.goTo(room1);
await util.assertRead(room2);
await util.receiveMessages(room2, [msg.redactionOf("ThreadMsg2")]);
await util.assertStillRead(room2);
await util.goTo(room2);
await util.assertReadThread("Root1");
// When I restart
await util.saveAndReload();
// Then the room is still read
await util.assertRead(room2);
// and so is the thread
await util.openThreadList();
await util.assertReadThread("Root1");
await util.assertReadThread("Root2");
});
test("A thread with an unread reply to a redacted message is still unread after restart", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a redacted message in a thread exists, but someone replied before it was redacted
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root",
msg.threadedOff("Root", "Msg2"),
msg.threadedOff("Root", "Msg3"),
msg.replyTo("Msg3", "Msg3Reply"),
]);
await util.assertUnread(room2, 1);
await util.receiveMessages(room2, [msg.redactionOf("Msg3")]);
// And we have read all this
await util.goTo(room2);
await util.openThread("Root");
await util.assertRead(room2);
await util.assertReadThread("Root");
// When I restart
await util.saveAndReload();
// Then the room is still read
await util.assertRead(room2);
await util.assertReadThread("Root");
});
test("A thread with a read reply to a redacted message is still read after restart", async ({
roomAlpha: room1,
roomBeta: room2,
util,
msg,
}) => {
// Given a redacted message in a thread exists, but someone replied before it was redacted
await util.goTo(room1);
await util.receiveMessages(room2, [
"Root",
msg.threadedOff("Root", "Msg2"),
msg.threadedOff("Root", "Msg3"),
msg.replyTo("Msg3", "Msg3Reply"),
]);
await util.assertUnread(room2, 1);
await util.receiveMessages(room2, [msg.redactionOf("Msg3")]);
// And I read it, so the room is read
await util.goTo(room2);
await util.openThread("Root");
await util.assertRead(room2);
await util.assertReadThread("Root");
// When I restart
await util.saveAndReload();
// Then the room is still read
await util.assertRead(room2);
await util.assertReadThread("Root");
});
});
});
});

View file

@ -0,0 +1,323 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* 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,224 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* 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,53 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/* 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

@ -0,0 +1,71 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { isDendrite } from "../../plugins/homeserver/dendrite";
test.describe("Email Registration", async () => {
test.skip(isDendrite, "not yet wired up");
test.use({
startHomeserverOpts: ({ mailhog }, use) =>
use({
template: "email",
variables: {
SMTP_HOST: "host.containers.internal",
SMTP_PORT: mailhog.instance.smtpPort,
},
}),
config: ({ homeserver }, use) =>
use({
default_server_config: {
"m.homeserver": {
base_url: homeserver.config.baseUrl,
},
"m.identity_server": {
base_url: "https://server.invalid",
},
},
}),
});
test.beforeEach(async ({ page }) => {
await page.goto("/#/register");
});
test("registers an account and lands on the use case selection screen", async ({
page,
mailhog,
request,
checkA11y,
}) => {
await expect(page.getByRole("textbox", { name: "Username" })).toBeVisible();
// Hide the server text as it contains the randomly allocated Homeserver port
const screenshotOptions = { mask: [page.locator(".mx_ServerPicker_server")] };
await page.getByRole("textbox", { name: "Username" }).fill("alice");
await page.getByPlaceholder("Password", { exact: true }).fill("totally a great password");
await page.getByPlaceholder("Confirm password").fill("totally a great password");
await page.getByPlaceholder("Email").fill("alice@email.com");
await page.getByRole("button", { name: "Register" }).click();
await expect(page.getByText("Check your email to continue")).toBeVisible();
await expect(page).toMatchScreenshot("registration_check_your_email.png", screenshotOptions);
await checkA11y();
await expect(page.getByText("An error was encountered when sending the email")).not.toBeVisible();
const messages = await mailhog.api.messages();
expect(messages.items).toHaveLength(1);
expect(messages.items[0].to).toEqual("alice@email.com");
const [emailLink] = messages.items[0].text.match(/http.+/);
await request.get(emailLink); // "Click" the link in the email
await expect(page.locator(".mx_UseCaseSelection_skip")).toBeVisible();
});
});

View file

@ -0,0 +1,116 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Registration", () => {
test.use({ startHomeserverOpts: "consent" });
test.beforeEach(async ({ page }) => {
await page.goto("/#/register");
});
test("registers an account and lands on the home screen", async ({ homeserver, page, checkA11y, crypto }) => {
await page.getByRole("button", { name: "Edit", exact: true }).click();
await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeVisible();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("server-picker.png");
await checkA11y();
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl);
await page.getByRole("button", { name: "Continue", exact: true }).click();
// wait for the dialog to go away
await expect(page.getByRole("dialog")).not.toBeVisible();
await expect(page.getByRole("textbox", { name: "Username", exact: true })).toBeVisible();
// Hide the server text as it contains the randomly allocated Homeserver port
const screenshotOptions = { mask: [page.locator(".mx_ServerPicker_server")], includeDialogBackground: true };
await expect(page).toMatchScreenshot("registration.png", screenshotOptions);
await checkA11y();
await page.getByRole("textbox", { name: "Username", exact: true }).fill("alice");
await page.getByPlaceholder("Password", { exact: true }).fill("totally a great password");
await page.getByPlaceholder("Confirm password", { exact: true }).fill("totally a great password");
await page.getByRole("button", { name: "Register", exact: true }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(page).toMatchScreenshot("email-prompt.png", screenshotOptions);
await checkA11y();
await dialog.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page.locator(".mx_InteractiveAuthEntryComponents_termsPolicy")).toBeVisible();
await expect(page).toMatchScreenshot("terms-prompt.png", screenshotOptions);
await checkA11y();
const termsPolicy = page.locator(".mx_InteractiveAuthEntryComponents_termsPolicy");
await termsPolicy.getByRole("checkbox").click(); // Click the checkbox before terms of service anchor link
await expect(termsPolicy.getByLabel("Privacy Policy")).toBeVisible();
await page.getByRole("button", { name: "Accept", exact: true }).click();
await expect(page.locator(".mx_UseCaseSelection_skip")).toBeVisible();
await expect(page).toMatchScreenshot("use-case-selection.png", screenshotOptions);
await checkA11y();
await page.getByRole("button", { name: "Skip", exact: true }).click();
await expect(page).toHaveURL(/\/#\/home$/);
/*
* Cross-signing checks
*/
// check that the device considers itself verified
await page.getByRole("button", { name: "User menu", exact: true }).click();
await page.getByRole("menuitem", { name: "All settings", exact: true }).click();
await page.getByRole("tab", { name: "Sessions", exact: true }).click();
await expect(page.getByTestId("current-session-section").getByTestId("device-metadata-isVerified")).toHaveText(
"Verified",
);
// check that cross-signing keys have been uploaded.
await crypto.assertDeviceIsCrossSigned();
});
test("should require username to fulfil requirements and be available", async ({ homeserver, page }) => {
await page.getByRole("button", { name: "Edit", exact: true }).click();
await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeVisible();
await page.getByRole("textbox", { name: "Other homeserver" }).fill(homeserver.config.baseUrl);
await page.getByRole("button", { name: "Continue", exact: true }).click();
// wait for the dialog to go away
await expect(page.getByRole("dialog")).not.toBeVisible();
await expect(page.getByRole("textbox", { name: "Username", exact: true })).toBeVisible();
await page.route("**/_matrix/client/*/register/available?username=_alice", async (route) => {
await route.fulfill({
status: 400,
json: {
errcode: "M_INVALID_USERNAME",
error: "User ID may not begin with _",
},
});
});
await page.getByRole("textbox", { name: "Username", exact: true }).fill("_alice");
await expect(page.getByRole("alert").filter({ hasText: "Some characters not allowed" })).toBeVisible();
await page.route("**/_matrix/client/*/register/available?username=bob", async (route) => {
await route.fulfill({
status: 400,
json: {
errcode: "M_USER_IN_USE",
error: "The desired username is already taken",
},
});
});
await page.getByRole("textbox", { name: "Username", exact: true }).fill("bob");
await expect(page.getByRole("alert").filter({ hasText: "Someone already has that username" })).toBeVisible();
await page.getByRole("textbox", { name: "Username", exact: true }).fill("foobar");
await expect(page.getByRole("alert")).not.toBeVisible();
});
});

View file

@ -0,0 +1,52 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Pills", () => {
test.use({
displayName: "Sally",
});
test("should navigate clicks internally to the app", async ({ page, app, user }) => {
const messageRoom = "Send Messages Here";
const targetLocalpart = "aliasssssssssssss";
await app.client.createRoom({
name: "Target",
room_alias_name: targetLocalpart,
});
const messageRoomId = await app.client.createRoom({
name: messageRoom,
});
await app.viewRoomByName(messageRoom);
await expect(page).toHaveURL(new RegExp(`/#/room/${messageRoomId}`));
// send a message using the built-in room mention functionality (autocomplete)
await page
.getByRole("textbox", { name: "Send a message…" })
.pressSequentially(`Hello world! Join here: #${targetLocalpart.substring(0, 3)}`);
await page.locator(".mx_Autocomplete_Completion_title").click();
await page.getByRole("button", { name: "Send message" }).click();
// find the pill in the timeline and click it
await page.locator(".mx_EventTile_body .mx_Pill").click();
const localUrl = new RegExp(`/#/room/#${targetLocalpart}:`);
// verify we landed at a sane place
await expect(page).toHaveURL(localUrl);
// go back to the message room and try to click on the pill text, as a user would
await app.viewRoomByName(messageRoom);
const pillText = page.locator(".mx_EventTile_body .mx_Pill .mx_Pill_text");
await expect(pillText).toHaveCSS("pointer-events", "none");
await pillText.click({ force: true }); // force is to ensure we bypass pointer-events
await expect(page).toHaveURL(localUrl);
});
});

View file

@ -0,0 +1,67 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import { Page } from "@playwright/test";
import { test as base, expect } from "../../element-web-test";
/**
* Set up for release announcement tests.
*/
export const test = base.extend<{
util: Helpers;
}>({
displayName: "Alice",
botCreateOpts: { displayName: "Other User" },
util: async ({ page, app, bot }, use) => {
await use(new Helpers(page));
},
});
export class Helpers {
constructor(private page: Page) {}
/**
* Get the release announcement with the given name.
* @param name
* @private
*/
private getReleaseAnnouncement(name: string) {
return this.page.getByRole("dialog", { name });
}
/**
* Assert that the release announcement with the given name is visible.
* @param name
*/
async assertReleaseAnnouncementIsVisible(name: string) {
await expect(this.getReleaseAnnouncement(name)).toBeVisible();
await expect(this.page).toMatchScreenshot(`release-announcement-${name}.png`);
}
/**
* Assert that the release announcement with the given name is not visible.
* @param name
*/
assertReleaseAnnouncementIsNotVisible(name: string) {
return expect(this.getReleaseAnnouncement(name)).not.toBeVisible();
}
/**
* Mark the release announcement with the given name as read.
* If the release announcement is not visible, this will throw an error.
* @param name
*/
async markReleaseAnnouncementAsRead(name: string) {
const dialog = this.getReleaseAnnouncement(name);
await dialog.getByRole("button", { name: "Ok" }).click();
}
}
export { expect };

View file

@ -0,0 +1,34 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "./";
test.describe("Release announcement", () => {
test.use({
config: {
features: {
feature_release_announcement: true,
},
},
labsFlags: ["threadsActivityCentre"],
});
test("should display the release announcement process", async ({ page, app, util }) => {
// The TAC release announcement should be displayed
await util.assertReleaseAnnouncementIsVisible("Threads Activity Centre");
// Hide the release announcement
await util.markReleaseAnnouncementAsRead("Threads Activity Centre");
await util.assertReleaseAnnouncementIsNotVisible("Threads Activity Centre");
await page.reload();
// Wait for EW to load
await expect(page.getByRole("navigation", { name: "Spaces" })).toBeVisible();
// Check that once the release announcement has been marked as viewed, it does not appear again
await util.assertReleaseAnnouncementIsNotVisible("Threads Activity Centre");
});
});

View file

@ -0,0 +1,214 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Suguru Hirahara
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Download, type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { viewRoomSummaryByName } from "./utils";
const ROOM_NAME = "Test room";
const NAME = "Alice";
async function uploadFile(page: Page, file: string) {
// Upload a file from the message composer
await page.locator(".mx_MessageComposer_actions input[type='file']").setInputFiles(file);
await page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click();
// Wait until the file is sent
await expect(page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible();
await expect(page.locator(".mx_EventTile.mx_EventTile_last .mx_EventTile_receiptSent")).toBeVisible();
}
test.describe("FilePanel", () => {
test.use({
displayName: NAME,
});
test.beforeEach(async ({ page, user, app }) => {
await app.client.createRoom({ name: ROOM_NAME });
// Open the file panel
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.getByRole("menuitem", { name: "Files" }).click();
await expect(page.locator(".mx_FilePanel")).toBeVisible();
});
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_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");
});
test("should list tiles on the panel", async ({ page }) => {
// Upload multiple files
await uploadFile(page, "playwright/sample-files/riot.png"); // Image
await uploadFile(page, "playwright/sample-files/1sec.ogg"); // Audio
await uploadFile(page, "playwright/sample-files/matrix-org-client-versions.json"); // JSON
const roomViewBody = page.locator(".mx_RoomView_body");
// Assert that all of the file were uploaded and rendered
await expect(roomViewBody.locator(".mx_EventTile[data-layout='group']")).toHaveCount(3);
// Assert that the image exists and has the alt string
await expect(roomViewBody.locator(".mx_EventTile[data-layout='group'] img[alt='riot.png']")).toBeVisible();
// Assert that the audio player is rendered
await expect(
roomViewBody.locator(".mx_EventTile[data-layout='group'] .mx_AudioPlayer_container"),
).toBeVisible();
// Assert that the file button exists
await expect(
roomViewBody.locator(".mx_EventTile_last[data-layout='group'] .mx_MFileBody", { hasText: ".json" }),
).toBeVisible();
const filePanel = page.locator(".mx_FilePanel");
// Assert that the file panel is opened inside mx_RightPanel and visible
await expect(filePanel).toBeVisible();
const filePanelMessageList = filePanel.locator(".mx_RoomView_MessageList");
// Assert that data-layout attribute is not applied to file tiles on the panel
await expect(filePanelMessageList.locator(".mx_EventTile[data-layout]")).not.toBeVisible();
// Assert that all of the file tiles are rendered
await expect(filePanelMessageList.locator(".mx_EventTile")).toHaveCount(3);
// Assert that the download links are rendered
await expect(filePanelMessageList.locator(".mx_MFileBody_download,.mx_MFileBody_info")).toHaveCount(3);
// Assert that the sender of the files is rendered on all of the tiles
await expect(filePanelMessageList.getByText(NAME)).toHaveCount(3);
// Detect the image file
const image = filePanelMessageList.locator(".mx_EventTile_mediaLine.mx_EventTile_image .mx_MImageBody");
// Assert that the image is specified as thumbnail and has the alt string
await expect(image.locator("img[class='mx_MImageBody_thumbnail']")).toBeVisible();
await expect(image.locator("img[alt='riot.png']")).toBeVisible();
// Detect the audio file
const audio = filePanelMessageList.locator(
".mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container",
);
// Assert that the play button is rendered
await expect(audio.getByRole("button", { name: "Play" })).toBeVisible();
// Detect the JSON file
// Assert that the tile is rendered as a button
const file = filePanelMessageList.locator(
".mx_EventTile_mediaLine .mx_MFileBody .mx_MFileBody_info[role='button'] .mx_MFileBody_info_filename",
);
// Assert that the file name is rendered inside the button with ellipsis
await expect(file.getByText(/matrix.*?\.json/)).toBeVisible();
// Make the viewport tall enough to display all of the file tiles on FilePanel
await page.setViewportSize({ width: 800, height: 1000 });
// In case the panel is scrollable on the resized viewport
// Assert that the value for flexbox is applied
await expect(filePanel.locator(".mx_ScrollPanel .mx_RoomView_MessageList")).toHaveCSS(
"justify-content",
"flex-end",
);
// Assert that all of the file tiles are visible before taking a snapshot
await expect(filePanelMessageList.locator(".mx_MImageBody")).toBeVisible(); // top
await expect(filePanelMessageList.locator(".mx_MAudioBody")).toBeVisible(); // middle
const senderDetails = filePanelMessageList.locator(".mx_EventTile_last .mx_EventTile_senderDetails");
await expect(senderDetails.locator(".mx_DisambiguatedProfile")).toBeVisible();
await expect(senderDetails.locator(".mx_MessageTimestamp")).toBeVisible();
// Take a snapshot of file tiles list on FilePanel
await expect(filePanelMessageList).toMatchScreenshot("file-tiles-list.png", {
// Exclude timestamps & flaky seek bar from snapshot
mask: [page.locator(".mx_MessageTimestamp, .mx_AudioPlayer_seek")],
});
});
test("should render the audio player and play the audio file on the panel", async ({ page }) => {
// Upload an image file
await uploadFile(page, "playwright/sample-files/1sec.ogg");
const audioBody = page.locator(
".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine .mx_MAudioBody .mx_AudioPlayer_container",
);
// Assert that the audio player is rendered
// Assert that the audio file information is rendered
const mediaInfo = audioBody.locator(".mx_AudioPlayer_mediaInfo");
await expect(mediaInfo.locator(".mx_AudioPlayer_mediaName").getByText("1sec.ogg")).toBeVisible();
await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "00:01" })).toBeVisible();
await expect(mediaInfo.locator(".mx_AudioPlayer_byline", { hasText: "(3.56 KB)" })).toBeVisible(); // actual size
// Assert that the duration counter is 00:01 before clicking the play button
await expect(audioBody.locator(".mx_AudioPlayer_mediaInfo time", { hasText: "00:01" })).toBeVisible();
// Assert that the counter is zero before clicking the play button
await expect(audioBody.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
// Click the play button
await audioBody.getByRole("button", { name: "Play" }).click();
// Assert that the pause button is rendered
await expect(audioBody.getByRole("button", { name: "Pause" })).toBeVisible();
// Assert that the timer is reset when the audio file finished playing
await expect(audioBody.locator(".mx_AudioPlayer_seek [role='timer']", { hasText: "00:00" })).toBeVisible();
// Assert that the play button is rendered
await expect(audioBody.getByRole("button", { name: "Play" })).toBeVisible();
});
test("should render file size in kibibytes on a file tile", async ({ page }) => {
const size = "1.12 KB"; // actual file size in kibibytes (1024 bytes)
// Upload a file
await uploadFile(page, "playwright/sample-files/matrix-org-client-versions.json");
const tile = page.locator(".mx_FilePanel .mx_EventTile");
// Assert that the file size is displayed in kibibytes, not kilobytes (1000 bytes)
// See: https://github.com/vector-im/element-web/issues/24866
await expect(tile.locator(".mx_MFileBody_info_filename", { hasText: size })).toBeVisible();
await expect(tile.locator(".mx_MFileBody_info", { hasText: size })).toBeVisible();
});
});
test.describe("download", () => {
test("should download an image via the link on the panel", async ({ page, context }) => {
// Upload an image file
await uploadFile(page, "playwright/sample-files/riot.png");
// Detect the image file on the panel
const imageBody = page.locator(
".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine.mx_EventTile_image .mx_MImageBody",
);
const link = imageBody.locator(".mx_MFileBody_download a");
const newPagePromise = context.waitForEvent("page");
const downloadPromise = new Promise<Download>((resolve) => {
page.once("download", resolve);
});
// Click the anchor link (not the image itself)
await link.click();
const newPage = await newPagePromise;
// XXX: Clicking the link opens the image in a new tab on some browsers rather than downloading
await expect(newPage)
.toHaveURL(/.+\/_matrix\/media\/\w+\/download\/localhost\/\w+/)
.catch(async () => {
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("riot.png");
});
});
});
});

View file

@ -0,0 +1,35 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Suguru Hirahara
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
const ROOM_NAME = "Test room";
const NAME = "Alice";
test.describe("NotificationPanel", () => {
test.use({
displayName: NAME,
labsFlags: ["feature_notifications"],
});
test.beforeEach(async ({ app, user }) => {
await app.client.createRoom({ name: ROOM_NAME });
});
test("should render empty state", async ({ page, app }) => {
await app.viewRoomByName(ROOM_NAME);
await page.getByRole("button", { name: "Notifications" }).click();
// Wait until the information about the empty state is rendered
await expect(page.locator(".mx_EmptyState")).toBeVisible();
// Take a snapshot of RightPanel
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("empty.png");
});
});

View file

@ -0,0 +1,143 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Locator, type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { checkRoomSummaryCard, viewRoomSummaryByName } from "./utils";
const ROOM_NAME = "Test room";
const ROOM_NAME_LONG =
"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore " +
"et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut " +
"aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum " +
"dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui " +
"officia deserunt mollit anim id est laborum.";
const SPACE_NAME = "Test space";
const NAME = "Alice";
const ROOM_ADDRESS_LONG =
"loremIpsumDolorSitAmetConsecteturAdipisicingElitSedDoEiusmodTemporIncididuntUtLaboreEtDoloreMagnaAliqua";
function getMemberTileByName(page: Page, name: string): Locator {
return page.locator(`.mx_EntityTile, [title="${name}"]`);
}
test.describe("RightPanel", () => {
test.use({
displayName: NAME,
});
test.beforeEach(async ({ app, user }) => {
await app.client.createRoom({ name: ROOM_NAME });
await app.client.createSpace({ name: SPACE_NAME });
});
test.describe("in rooms", () => {
test("should handle long room address and long room name", async ({ page, app }) => {
await app.client.createRoom({ name: ROOM_NAME_LONG });
await viewRoomSummaryByName(page, app, ROOM_NAME_LONG);
await app.settings.openRoomSettings();
// Set a local room address
const localAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Local Addresses" });
await localAddresses.getByRole("textbox").fill(ROOM_ADDRESS_LONG);
await localAddresses.getByRole("button", { name: "Add" }).click();
await expect(localAddresses.getByText(`#${ROOM_ADDRESS_LONG}:localhost`)).toHaveClass(
"mx_EditableItem_item",
);
await app.closeDialog();
// Close and reopen the right panel to render the room address
await app.toggleRoomInfoPanel();
await expect(page.locator(".mx_RightPanel")).not.toBeVisible();
await app.toggleRoomInfoPanel();
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-name-and-address.png");
});
test("should handle clicking add widgets", async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.getByRole("menuitem", { name: "Extensions" }).click();
await page.getByRole("button", { name: "Add extensions" }).click();
await expect(page.locator(".mx_IntegrationManager")).toBeVisible();
});
test("should handle viewing export chat", async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.getByRole("menuitem", { name: "Export Chat" }).click();
await expect(page.locator(".mx_ExportDialog")).toBeVisible();
});
test("should handle viewing share room", async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.getByRole("menuitem", { name: "Copy link" }).click();
await expect(page.locator(".mx_ShareDialog")).toBeVisible();
});
test("should handle viewing room settings", async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.getByRole("menuitem", { name: "Settings" }).click();
await expect(page.locator(".mx_RoomSettingsDialog")).toBeVisible();
await expect(page.locator(".mx_Dialog_title").getByText("Room Settings - " + ROOM_NAME)).toBeVisible();
});
test("should handle viewing files", async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.getByRole("menuitem", { name: "Files" }).click();
await expect(page.locator(".mx_FilePanel")).toBeVisible();
await expect(page.locator(".mx_EmptyState")).toBeVisible();
await page.getByTestId("base-card-back-button").click();
await checkRoomSummaryCard(page, ROOM_NAME);
});
test("should handle viewing room member", async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
await getMemberTileByName(page, NAME).click();
await expect(page.locator(".mx_UserInfo")).toBeVisible();
await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible();
await page.getByTestId("base-card-back-button").click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
await page.getByLabel("Room info").nth(1).click();
await checkRoomSummaryCard(page, ROOM_NAME);
});
});
test.describe("in spaces", () => {
test("should handle viewing space member", async ({ page, app }) => {
await app.viewSpaceHomeByName(SPACE_NAME);
// \d represents the number of the space members
await page
.locator(".mx_RoomInfoLine_private")
.getByRole("button", { name: /\d member/ })
.click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
await getMemberTileByName(page, NAME).click();
await expect(page.locator(".mx_UserInfo")).toBeVisible();
await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible();
await page.getByTestId("base-card-back-button").click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
});
});
});

View file

@ -0,0 +1,22 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { type Page, expect } from "@playwright/test";
import { ElementAppPage } from "../../pages/ElementAppPage";
export async function viewRoomSummaryByName(page: Page, app: ElementAppPage, name: string): Promise<void> {
await app.viewRoomByName(name);
await app.toggleRoomInfoPanel();
return checkRoomSummaryCard(page, name);
}
export async function checkRoomSummaryCard(page: Page, name: string): Promise<void> {
await expect(page.locator(".mx_RoomSummaryCard")).toBeVisible();
await expect(page.locator(".mx_RoomSummaryCard")).toContainText(name);
}

View file

@ -0,0 +1,80 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import type { Preset, Visibility } from "matrix-js-sdk/src/matrix";
import { test, expect } from "../../element-web-test";
test.describe("Room Directory", () => {
test.use({
displayName: "Ray",
botCreateOpts: { displayName: "Paul" },
});
test("should allow admin to add alias & publish room to directory", async ({ page, app, user, bot }) => {
const roomId = await app.client.createRoom({
name: "Gaming",
preset: "public_chat" as Preset,
});
await app.viewRoomByName("Gaming");
await app.settings.openRoomSettings();
// First add a local address `gaming`
const localAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Local Addresses" });
await localAddresses.getByRole("textbox").fill("gaming");
await localAddresses.getByRole("button", { name: "Add" }).click();
await expect(localAddresses.getByText("#gaming:localhost")).toHaveClass("mx_EditableItem_item");
// Publish into the public rooms directory
const publishedAddresses = page.locator(".mx_SettingsFieldset", { hasText: "Published Addresses" });
await expect(publishedAddresses.locator("#canonicalAlias")).toHaveValue("#gaming:localhost");
const checkbox = publishedAddresses
.locator(".mx_SettingsFlag", { hasText: "Publish this room to the public in localhost's room directory?" })
.getByRole("switch");
await checkbox.check();
await expect(checkbox).toBeChecked();
await app.closeDialog();
const resp = await bot.publicRooms({});
expect(resp.total_room_count_estimate).toEqual(1);
expect(resp.chunk).toHaveLength(1);
expect(resp.chunk[0].room_id).toEqual(roomId);
});
test("should allow finding published rooms in directory", async ({ page, app, user, bot }) => {
const name = "This is a public room";
await bot.createRoom({
visibility: "public" as Visibility,
name,
room_alias_name: "test1234",
});
await page.getByRole("button", { name: "Explore rooms" }).click();
const dialog = page.locator(".mx_SpotlightDialog");
await dialog.getByRole("textbox", { name: "Search" }).fill("Unknown Room");
await expect(
dialog.getByText("If you can't find the room you're looking for, ask for an invite or create a new room."),
).toHaveClass("mx_SpotlightDialog_otherSearches_messageSearchText");
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("filtered-no-results.png");
await dialog.getByRole("textbox", { name: "Search" }).fill("test1234");
await expect(dialog.getByText(name)).toHaveClass("mx_SpotlightDialog_result_publicRoomName");
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("filtered-one-result.png");
await page
.locator(".mx_SpotlightDialog .mx_SpotlightDialog_option")
.getByRole("button", { name: "Join" })
.click();
await expect(page).toHaveURL("/#/room/#test1234:localhost");
});
});

View file

@ -0,0 +1,146 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Suguru Hirahara
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { ElementAppPage } from "../../pages/ElementAppPage";
test.describe("Room Header", () => {
test.use({
displayName: "Sakura",
});
test.describe("with feature_notifications enabled", () => {
test.use({
labsFlags: ["feature_notifications"],
});
test("should render default buttons properly", async ({ page, app, user }) => {
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
const header = page.locator(".mx_RoomHeader");
// 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();
// 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 eight buttons in total
await expect(header.getByRole("button")).toHaveCount(8);
await expect(header).toMatchScreenshot("room-header.png");
});
test("should render a very long room name without collapsing the buttons", async ({ page, app, user }) => {
const LONG_ROOM_NAME =
"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore " +
"et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut " +
"aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum " +
"dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui " +
"officia deserunt mollit anim id est laborum.";
await app.client.createRoom({ name: LONG_ROOM_NAME });
await app.viewRoomByName(LONG_ROOM_NAME);
const header = page.locator(".mx_RoomHeader");
// Wait until the room name is set
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 = 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");
await expect(button).toHaveCSS("width", "32px");
}
await expect(header).toMatchScreenshot("room-header-long-name.png");
});
});
test.describe("with a video room", () => {
test.use({ labsFlags: ["feature_video_rooms"] });
const createVideoRoom = async (page: Page, app: ElementAppPage) => {
await page.locator(".mx_LeftPanel_roomListContainer").getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "New video room" }).click();
await page.getByRole("textbox", { name: "Name" }).type("Test video room");
await page.getByRole("button", { name: "Create video room" }).click();
await app.viewRoomByName("Test video room");
};
test.describe("and with feature_notifications enabled", () => {
test.use({ labsFlags: ["feature_video_rooms", "feature_notifications"] });
test("should render buttons for chat, room info, threads and facepile", async ({ page, app, user }) => {
await createVideoRoom(page, app);
const header = page.locator(".mx_RoomHeader");
// 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).toMatchScreenshot("room-header-video-room.png");
});
});
test("should render a working chat button which opens the timeline on a right panel", async ({
page,
app,
user,
}) => {
await createVideoRoom(page, app);
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();
// Assert that GELS is visible
await expect(
page.locator(".mx_RightPanel .mx_TimelineCard").getByText("Sakura created and configured the room."),
).toBeVisible();
});
});
});

View file

@ -0,0 +1,98 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import type { EventType } from "matrix-js-sdk/src/matrix";
import { test, expect } from "../../element-web-test";
import { Bot } from "../../pages/bot";
test.describe("Room Directory", () => {
test.use({
displayName: "Alice",
});
test("should switch between existing dm rooms without a loader", async ({ page, homeserver, app, user }) => {
const bob = new Bot(page, homeserver, { displayName: "Bob" });
await bob.prepareClient();
const charlie = new Bot(page, homeserver, { displayName: "Charlie" });
await charlie.prepareClient();
// create dms with bob and charlie
await app.client.evaluate(
async (cli, { bob, charlie }) => {
const bobRoom = await cli.createRoom({ is_direct: true });
const charlieRoom = await cli.createRoom({ is_direct: true });
await cli.invite(bobRoom.room_id, bob);
await cli.invite(charlieRoom.room_id, charlie);
await cli.setAccountData("m.direct" as EventType, {
[bob]: [bobRoom.room_id],
[charlie]: [charlieRoom.room_id],
});
},
{
bob: bob.credentials.userId,
charlie: charlie.credentials.userId,
},
);
await app.viewRoomByName("Bob");
// short timeout because loader is only visible for short period
// we want to make sure it is never displayed when switching these rooms
await expect(page.locator(".mx_RoomPreviewBar_spinnerTitle")).not.toBeVisible({ timeout: 1 });
// confirm the room was loaded
await expect(page.getByText("Bob joined the room")).toBeVisible();
await app.viewRoomByName("Charlie");
await expect(page.locator(".mx_RoomPreviewBar_spinnerTitle")).not.toBeVisible({ timeout: 1 });
// confirm the room was loaded
await expect(page.getByText("Charlie joined the room")).toBeVisible();
});
test("should memorize the timeline position when switch Room A -> Room B -> Room A", async ({
page,
app,
user,
}) => {
// Create the two rooms
const roomAId = await app.client.createRoom({ name: "Room A" });
const roomBId = await app.client.createRoom({ name: "Room B" });
// Display Room A
await app.viewRoomById(roomAId);
// Send the first message and get the event ID
const { event_id: eventId } = await app.client.sendMessage(roomAId, { body: "test0", msgtype: "m.text" });
// Send 49 more messages
for (let i = 1; i < 50; i++) {
await app.client.sendMessage(roomAId, { body: `test${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("test49"),
).toBeVisible();
// Display the first message
await page.goto(`/#/room/${roomAId}/${eventId}`);
// Wait for the first message to be displayed
await expect(page.locator(".mx_MTextBody .mx_EventTile_body").getByText("test0")).toBeInViewport();
// Display Room B
await app.viewRoomById(roomBId);
// Let the app settle to avoid flakiness
await page.waitForTimeout(500);
// Display Room A
await app.viewRoomById(roomAId);
// The timeline should display the first message
// The previous position before switching to Room B should be remembered
await expect(page.locator(".mx_MTextBody .mx_EventTile_body").getByText("test0")).toBeInViewport();
});
});

View file

@ -0,0 +1,53 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
const TEST_ROOM_NAME = "The mark unread test room";
test.describe("Mark as Unread", () => {
test.use({
displayName: "Tom",
botCreateOpts: {
displayName: "BotBob",
autoAcceptInvites: true,
},
});
test("should mark a room as unread", async ({ page, app, bot }) => {
const roomId = await app.client.createRoom({
name: TEST_ROOM_NAME,
});
const dummyRoomId = await app.client.createRoom({
name: "Room of no consequence",
});
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
await bot.sendMessage(roomId, "I am a robot. Beep.");
// Regular notification on new message
await expect(page.getByLabel(TEST_ROOM_NAME + " 1 unread message.")).toBeVisible();
await expect(page).toHaveTitle("Element [1]");
await page.goto("/#/room/" + roomId);
// should now be read, since we viewed the room (we have to assert the page title:
// the room badge isn't visible since we're viewing the room)
await expect(page).toHaveTitle("Element | " + TEST_ROOM_NAME);
// navigate away from the room again
await page.goto("/#/room/" + dummyRoomId);
const roomTile = page.getByLabel(TEST_ROOM_NAME);
await roomTile.focus();
await roomTile.getByRole("button", { name: "Room options" }).click();
await page.getByRole("menuitem", { name: "Mark as unread" }).click();
expect(page.getByLabel(TEST_ROOM_NAME + " Unread messages.")).toBeVisible();
});
});

View file

@ -0,0 +1,143 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Suguru Hirahara
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
const USER_NAME = "Bob";
const USER_NAME_NEW = "Alice";
test.describe("Account user settings tab", () => {
test.use({
displayName: USER_NAME,
config: {
default_country_code: "US", // For checking the international country calling code
},
uut: async ({ app, user }, use) => {
const locator = await app.settings.openUserSettings("Account");
await use(locator);
},
});
test("should be rendered properly", async ({ uut, user }) => {
await expect(uut).toMatchScreenshot("account.png");
// Assert that the top heading is rendered
await expect(uut.getByRole("heading", { name: "Account", exact: true })).toBeVisible();
const profile = uut.locator(".mx_UserProfileSettings_profile");
await profile.scrollIntoViewIfNeeded();
await expect(profile.getByRole("textbox", { name: "Display Name" })).toHaveValue(USER_NAME);
// Assert that a userId is rendered
expect(uut.getByLabel("Username")).toHaveText(user.userId);
// Wait until spinners disappear
await expect(uut.getByTestId("accountSection").locator(".mx_Spinner")).not.toBeVisible();
await expect(uut.getByTestId("discoverySection").locator(".mx_Spinner")).not.toBeVisible();
const accountSection = uut.getByTestId("accountSection");
accountSection.scrollIntoViewIfNeeded();
// Assert that input areas for changing a password exists
await expect(accountSection.getByLabel("Current password")).toBeVisible();
await expect(accountSection.getByLabel("New Password")).toBeVisible();
await expect(accountSection.getByLabel("Confirm password")).toBeVisible();
// Check email addresses area
const emailAddresses = uut.getByTestId("mx_AccountEmailAddresses");
await emailAddresses.scrollIntoViewIfNeeded();
// Assert that an input area for a new email address is rendered
await expect(emailAddresses.getByRole("textbox", { name: "Email Address" })).toBeVisible();
// Assert the add button is visible
await expect(emailAddresses.getByRole("button", { name: "Add" })).toBeVisible();
// Check phone numbers area
const phoneNumbers = uut.getByTestId("mx_AccountPhoneNumbers");
await phoneNumbers.scrollIntoViewIfNeeded();
// Assert that an input area for a new phone number is rendered
await expect(phoneNumbers.getByRole("textbox", { name: "Phone Number" })).toBeVisible();
// Assert that the add button is rendered
await expect(phoneNumbers.getByRole("button", { name: "Add" })).toBeVisible();
// Assert the account deactivation button is displayed
const accountManagementSection = uut.getByTestId("account-management-section");
await accountManagementSection.scrollIntoViewIfNeeded();
await expect(accountManagementSection.getByRole("button", { name: "Deactivate Account" })).toHaveClass(
/mx_AccessibleButton_kind_danger/,
);
});
test("should respond to small screen sizes", async ({ page, uut }) => {
await page.setViewportSize({ width: 700, height: 600 });
await expect(uut).toMatchScreenshot("account-smallscreen.png");
});
test("should show tooltips on narrow screen", async ({ page, uut }) => {
await page.setViewportSize({ width: 700, height: 600 });
await page.getByRole("tab", { name: "Account" }).hover();
await expect(page.getByRole("tooltip")).toHaveText("Account");
});
test("should support adding and removing a profile picture", async ({ uut, page }) => {
const profileSettings = uut.locator(".mx_UserProfileSettings");
// Upload a picture
await profileSettings.getByAltText("Upload").setInputFiles("playwright/sample-files/riot.png");
// Image should be visible
await expect(profileSettings.locator(".mx_AvatarSetting_avatar img")).toBeVisible();
// Open the menu & click remove
await profileSettings.getByRole("button", { name: "Profile Picture" }).click();
await page.getByRole("menuitem", { name: "Remove" }).click();
// Assert that the image disappeared
await expect(profileSettings.locator(".mx_AvatarSetting_avatar img")).not.toBeVisible();
});
test("should set a country calling code based on default_country_code", async ({ uut }) => {
// Check phone numbers area
const accountPhoneNumbers = uut.getByTestId("mx_AccountPhoneNumbers");
await accountPhoneNumbers.scrollIntoViewIfNeeded();
// Assert that an input area for a new phone number is rendered
await expect(accountPhoneNumbers.getByRole("textbox", { name: "Phone Number" })).toBeVisible();
// Check a new phone number dropdown menu
const dropdown = accountPhoneNumbers.locator(".mx_PhoneNumbers_country");
await dropdown.scrollIntoViewIfNeeded();
// Assert that the country calling code of the United States is visible
await expect(dropdown.getByText(/\+1/)).toBeVisible();
// Click the button to display the dropdown menu
await dropdown.getByRole("button", { name: "Country Dropdown" }).click();
// Assert that the option for calling code of the United Kingdom is visible
await expect(dropdown.getByRole("option", { name: /United Kingdom/ })).toBeVisible();
// Click again to close the dropdown
await dropdown.getByRole("button", { name: "Country Dropdown" }).click();
// Assert that the default value is rendered again
await expect(dropdown.getByText(/\+1/)).toBeVisible();
await expect(accountPhoneNumbers.getByRole("button", { name: "Add" })).toBeVisible();
});
test("should support changing a display name", async ({ uut, page, app }) => {
// Change the diaplay name to USER_NAME_NEW
const displayNameInput = uut
.locator(".mx_SettingsTab .mx_UserProfileSettings")
.getByRole("textbox", { name: "Display Name" });
await displayNameInput.fill(USER_NAME_NEW);
await displayNameInput.press("Enter");
await app.closeDialog();
// Assert the avatar's initial characters are set
await expect(page.locator(".mx_UserMenu .mx_BaseAvatar").getByText("A")).toBeVisible(); // Alice
await expect(page.locator(".mx_RoomView_wrapper .mx_BaseAvatar").getByText("A")).toBeVisible(); // Alice
});
});

View file

@ -0,0 +1,55 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Suguru Hirahara
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { expect, test } from ".";
test.describe("Appearance user settings tab", () => {
test.use({
displayName: "Hanako",
});
test("should be rendered properly", async ({ page, user, app }) => {
const tab = await app.settings.openUserSettings("Appearance");
// Click "Show advanced" link button
await tab.getByRole("button", { name: "Show advanced" }).click();
// Assert that "Hide advanced" link button is rendered
await expect(tab.getByRole("button", { name: "Hide advanced" })).toBeVisible();
await expect(tab).toMatchScreenshot("appearance-tab.png");
});
test("should support changing font size by using the font size dropdown", async ({ page, app, user }) => {
await app.settings.openUserSettings("Appearance");
const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
const fontDropdown = tab.locator(".mx_FontScalingPanel_Dropdown");
await expect(fontDropdown.getByLabel("Font size")).toBeVisible();
// Default browser font size is 16px and the select value is 0
// -4 value is 12px
await fontDropdown.getByLabel("Font size").selectOption({ value: "-4" });
await expect(page).toMatchScreenshot("window-12px.png", { includeDialogBackground: true });
});
test("should support enabling system font", async ({ page, app, user }) => {
await app.settings.openUserSettings("Appearance");
const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
// Click "Show advanced" link button
await tab.getByRole("button", { name: "Show advanced" }).click();
await tab.locator(".mx_Checkbox", { hasText: "Use bundled emoji font" }).click();
await tab.locator(".mx_Checkbox", { hasText: "Use a system font" }).click();
// Assert that the font-family value was removed
await expect(page.locator("body")).toHaveCSS("font-family", '""');
});
});

View file

@ -0,0 +1,233 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import { Locator, Page } from "@playwright/test";
import { ElementAppPage } from "../../../pages/ElementAppPage";
import { test as base, expect } from "../../../element-web-test";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import { Layout } from "../../../../src/settings/enums/Layout";
export { expect };
/**
* Set up for the appearance tab test
*/
export const test = base.extend<{
util: Helpers;
}>({
util: async ({ page, app }, use) => {
await use(new Helpers(page, app));
},
});
/**
* A collection of helper functions for the appearance tab test
* The goal is to make easier to get and interact with the button, input, or other elements of the appearance tab
*/
class Helpers {
private CUSTOM_THEME_URL = "http://custom.theme";
private CUSTOM_THEME = {
name: "Custom theme",
isDark: false,
colors: {},
};
constructor(
private page: Page,
private app: ElementAppPage,
) {}
/**
* Open the appearance tab
*/
openAppearanceTab() {
return this.app.settings.openUserSettings("Appearance");
}
/**
* Compare screenshot and hide the matrix chat
* @param locator
* @param screenshot
*/
assertScreenshot(locator: Locator, screenshot: `${string}.png`) {
return expect(locator).toMatchScreenshot(screenshot, {
css: `
#matrixchat {
display: none;
}
`,
});
}
// Theme Panel
/**
* Disable in the settings the system theme
*/
disableSystemTheme() {
return this.app.settings.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
}
/**
* Return the theme section
*/
getThemePanel() {
return this.page.getByTestId("themePanel");
}
/**
* Return the system theme toggle
*/
getMatchSystemThemeCheckbox() {
return this.getThemePanel().getByRole("checkbox", { name: "Match system theme" });
}
/**
* Return the theme radio button
* @param theme - the theme to select
* @private
*/
private getThemeRadio(theme: string) {
return this.getThemePanel().getByRole("radio", { name: theme });
}
/**
* Return the light theme radio button
*/
getLightTheme() {
return this.getThemeRadio("Light");
}
/**
* Return the dark theme radio button
*/
getDarkTheme() {
return this.getThemeRadio("Dark");
}
/**
* Return the custom theme radio button
*/
getCustomTheme() {
return this.getThemeRadio(this.CUSTOM_THEME.name);
}
/**
* Return the high contrast theme radio button
*/
getHighContrastTheme() {
return this.getThemeRadio("High contrast");
}
/**
* Add a custom theme
* Mock the request to the custom and return a fake local custom theme
*/
async addCustomTheme() {
await this.page.route(this.CUSTOM_THEME_URL, (route) =>
route.fulfill({ body: JSON.stringify(this.CUSTOM_THEME) }),
);
await this.page.getByRole("textbox", { name: "Add custom theme" }).fill(this.CUSTOM_THEME_URL);
await this.page.getByRole("button", { name: "Add custom theme" }).click();
await this.page.unroute(this.CUSTOM_THEME_URL);
}
/**
* Remove the custom theme
*/
removeCustomTheme() {
return this.getThemePanel().getByRole("listitem", { name: this.CUSTOM_THEME.name }).getByRole("button").click();
}
// Message layout Panel
/**
* Create and display a room named Test Room
*/
async createAndDisplayRoom() {
await this.app.client.createRoom({ name: "Test Room" });
await this.app.viewRoomByName("Test Room");
}
/**
* Assert the room layout
* @param layout
* @private
*/
private assertRoomLayout(layout: Layout) {
return expect(this.page.locator(`.mx_RoomView_body[data-layout=${layout}]`)).toBeVisible();
}
/**
* Assert the room layout is modern
*/
assertModernLayout() {
return this.assertRoomLayout(Layout.Group);
}
/**
* Assert the room layout is bubble
*/
assertBubbleLayout() {
return this.assertRoomLayout(Layout.Bubble);
}
/**
* Return the layout panel
*/
getMessageLayoutPanel() {
return this.page.getByTestId("layoutPanel");
}
/**
* Return the layout radio button
* @param layoutName
* @private
*/
private getLayout(layoutName: string) {
return this.getMessageLayoutPanel().getByRole("radio", { name: layoutName });
}
/**
* Return the message bubbles layout radio button
*/
getBubbleLayout() {
return this.getLayout("Message bubbles");
}
/**
* Return the modern layout radio button
*/
getModernLayout() {
return this.getLayout("Modern");
}
/**
* Return the IRC layout radio button
*/
getIRCLayout() {
return this.getLayout("IRC (experimental)");
}
/**
* Return the compact layout checkbox
*/
getCompactLayoutCheckbox() {
return this.getMessageLayoutPanel().getByRole("checkbox", { name: "Show compact text and messages" });
}
/**
* Assert the compact layout is enabled
*/
assertCompactLayout() {
return expect(
this.page.locator("#matrixchat .mx_MatrixChat_wrapper.mx_MatrixChat_useCompactLayout"),
).toBeVisible();
}
}

View file

@ -0,0 +1,58 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Suguru Hirahara
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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,81 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Suguru Hirahara
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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

@ -0,0 +1,97 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("Device manager", () => {
test.use({
displayName: "Alice",
});
test.beforeEach(async ({ homeserver, user }) => {
// create 3 extra sessions to manage
for (let i = 0; i < 3; i++) {
await homeserver.loginUser(user.userId, user.password);
}
});
test("should display sessions", async ({ page, app }) => {
await app.settings.openUserSettings("Sessions");
const tab = page.locator(".mx_SettingsTab");
await expect(tab.getByText("Current session", { exact: true })).toBeVisible();
const currentSessionSection = tab.getByTestId("current-session-section");
await expect(currentSessionSection.getByText("Unverified session")).toBeVisible();
// current session details opened
await currentSessionSection.getByRole("button", { name: "Show details" }).click();
await expect(currentSessionSection.getByText("Session details")).toBeVisible();
// close current session details
await currentSessionSection.getByRole("button", { name: "Hide details" }).click();
await expect(currentSessionSection.getByText("Session details")).not.toBeVisible();
const securityRecommendationsSection = tab.getByTestId("security-recommendations-section");
await expect(securityRecommendationsSection.getByText("Security recommendations")).toBeVisible();
await securityRecommendationsSection.getByRole("button", { name: "View all (3)" }).click();
/**
* Other sessions section
*/
await expect(tab.getByText("Other sessions")).toBeVisible();
// filter applied after clicking through from security recommendations
await expect(tab.getByLabel("Filter devices")).toHaveText("Show: Unverified");
const filteredDeviceListItems = tab.locator(".mx_FilteredDeviceList_listItem");
await expect(filteredDeviceListItems).toHaveCount(3);
// select two sessions
// force click as the input element itself is not visible (its size is zero)
await filteredDeviceListItems.first().click({ force: true });
await filteredDeviceListItems.last().click({ force: true });
// sign out from list selection action buttons
await tab.getByRole("button", { name: "Sign out", exact: true }).click();
await page.getByRole("dialog").getByTestId("dialog-primary-button").click();
// list updated after sign out
await expect(filteredDeviceListItems).toHaveCount(1);
// security recommendation count updated
await expect(tab.getByRole("button", { name: "View all (1)" })).toBeVisible();
const sessionName = `Alice's device`;
// open the first session
const firstSession = filteredDeviceListItems.first();
await firstSession.getByRole("button", { name: "Show details" }).click();
await expect(firstSession.getByText("Session details")).toBeVisible();
await firstSession.getByRole("button", { name: "Rename" }).click();
await firstSession.getByTestId("device-rename-input").type(sessionName);
await firstSession.getByRole("button", { name: "Save" }).click();
// there should be a spinner while device updates
await expect(firstSession.locator(".mx_Spinner")).toBeVisible();
// wait for spinner to complete
await expect(firstSession.locator(".mx_Spinner")).not.toBeVisible();
// session name updated in details
await expect(firstSession.locator(".mx_DeviceDetailHeading h4").getByText(sessionName)).toBeVisible();
// and main list item
await expect(firstSession.locator(".mx_DeviceTile h4").getByText(sessionName)).toBeVisible();
// sign out using the device details sign out
await firstSession.getByRole("button", { name: "Sign out of this session" }).click();
// confirm the signout
await page.getByRole("dialog").getByTestId("dialog-primary-button").click();
// no other sessions or security recommendations sections when only one session
await expect(tab.getByText("Other sessions")).not.toBeVisible();
await expect(tab.getByTestId("security-recommendations-section")).not.toBeVisible();
});
});

View file

@ -0,0 +1,55 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Suguru Hirahara
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
test.describe("General room settings tab", () => {
const roomName = "Test Room";
test.use({
displayName: "Hanako",
});
test.beforeEach(async ({ user, app }) => {
await app.client.createRoom({ name: roomName });
await app.viewRoomByName(roomName);
});
test("should be rendered properly", async ({ page, app }) => {
const settings = await app.settings.openRoomSettings("General");
// Assert that "Show less" details element is rendered
await expect(settings.getByText("Show less")).toBeVisible();
await expect(settings).toMatchScreenshot("General-room-settings-tab-should-be-rendered-properly-1.png");
// Click the "Show less" details element
await settings.getByText("Show less").click();
// Assert that "Show more" details element is rendered instead of "Show more"
await expect(settings.getByText("Show less")).not.toBeVisible();
await expect(settings.getByText("Show more")).toBeVisible();
});
test("long address should not cause dialog to overflow", async ({ page, app }) => {
const settings = await app.settings.openRoomSettings("General");
// 1. Set the room-address to be a really long string
const longString = "abcasdhjasjhdaj1jh1asdhasjdhajsdhjavhjksd".repeat(4);
await settings.locator("#roomAliases input[label='Room address']").fill(longString);
await settings.locator("#roomAliases").getByText("Add", { exact: true }).click();
// 2. wait for the new setting to apply ...
await expect(settings.locator("#canonicalAlias")).toHaveValue(`#${longString}:localhost`);
// 3. Check if the dialog overflows
const dialogBoundingBox = await page.locator(".mx_Dialog").boundingBox();
const inputBoundingBox = await settings.locator("#canonicalAlias").boundingBox();
// Assert that the width of the select element is less than that of .mx_Dialog div.
expect(inputBoundingBox.width).toBeLessThan(dialogBoundingBox.width);
});
});

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