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:
commit
f0ee7f7905
3265 changed files with 484599 additions and 699 deletions
6
playwright/.gitignore
vendored
Normal file
6
playwright/.gitignore
vendored
Normal 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
12
playwright/@types/playwright-core.d.ts
vendored
Normal 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
9
playwright/Dockerfile
Normal 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"]
|
5
playwright/docker-entrypoint.sh
Normal file
5
playwright/docker-entrypoint.sh
Normal file
|
@ -0,0 +1,5 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
npx playwright test --update-snapshots --reporter line $@
|
158
playwright/e2e/accessibility/keyboard-navigation.spec.ts
Normal file
158
playwright/e2e/accessibility/keyboard-navigation.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
34
playwright/e2e/app-loading/feature-detection.spec.ts
Normal file
34
playwright/e2e/app-loading/feature-detection.spec.ts
Normal 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");
|
||||
});
|
37
playwright/e2e/app-loading/guest-registration.spec.ts
Normal file
37
playwright/e2e/app-loading/guest-registration.spec.ts
Normal 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();
|
||||
});
|
60
playwright/e2e/app-loading/stored-credentials.spec.ts
Normal file
60
playwright/e2e/app-loading/stored-credentials.spec.ts
Normal 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();
|
||||
});
|
347
playwright/e2e/audio-player/audio-player.spec.ts
Normal file
347
playwright/e2e/audio-player/audio-player.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
128
playwright/e2e/chat-export/html-export.spec.ts
Normal file
128
playwright/e2e/chat-export/html-export.spec.ts
Normal 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"),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
98
playwright/e2e/composer/CIDER.spec.ts
Normal file
98
playwright/e2e/composer/CIDER.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
350
playwright/e2e/composer/RTE.spec.ts
Normal file
350
playwright/e2e/composer/RTE.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
34
playwright/e2e/create-room/create-room.spec.ts
Normal file
34
playwright/e2e/create-room/create-room.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
112
playwright/e2e/crypto/backups.spec.ts
Normal file
112
playwright/e2e/crypto/backups.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
27
playwright/e2e/crypto/complete-security.spec.ts
Normal file
27
playwright/e2e/crypto/complete-security.spec.ts
Normal 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`.
|
||||
});
|
248
playwright/e2e/crypto/crypto.spec.ts
Normal file
248
playwright/e2e/crypto/crypto.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
294
playwright/e2e/crypto/decryption-failure-messages.spec.ts
Normal file
294
playwright/e2e/crypto/decryption-failure-messages.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
106
playwright/e2e/crypto/dehydration.spec.ts
Normal file
106
playwright/e2e/crypto/dehydration.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
259
playwright/e2e/crypto/device-verification.spec.ts
Normal file
259
playwright/e2e/crypto/device-verification.spec.ts
Normal 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);
|
||||
}
|
311
playwright/e2e/crypto/event-shields.spec.ts
Normal file
311
playwright/e2e/crypto/event-shields.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
56
playwright/e2e/crypto/invisible-crypto.spec.ts
Normal file
56
playwright/e2e/crypto/invisible-crypto.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
60
playwright/e2e/crypto/logout.spec.ts
Normal file
60
playwright/e2e/crypto/logout.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
64
playwright/e2e/crypto/migration.spec.ts
Normal file
64
playwright/e2e/crypto/migration.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
|
@ -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`.
|
71732
playwright/e2e/crypto/test_indexeddb_cryptostore_dump/dump.json
Normal file
71732
playwright/e2e/crypto/test_indexeddb_cryptostore_dump/dump.json
Normal file
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,6 @@
|
|||
<html>
|
||||
<head>
|
||||
<script src="load.js"></script>
|
||||
</head>
|
||||
Loading test data...
|
||||
</html>
|
220
playwright/e2e/crypto/test_indexeddb_cryptostore_dump/load.js
Normal file
220
playwright/e2e/crypto/test_indexeddb_cryptostore_dump/load.js
Normal 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();
|
137
playwright/e2e/crypto/user-verification.spec.ts
Normal file
137
playwright/e2e/crypto/user-verification.spec.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
390
playwright/e2e/crypto/utils.ts
Normal file
390
playwright/e2e/crypto/utils.ts
Normal 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;
|
||||
}
|
366
playwright/e2e/editing/editing.spec.ts
Normal file
366
playwright/e2e/editing/editing.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
37
playwright/e2e/file-upload/image-upload.spec.ts
Normal file
37
playwright/e2e/file-upload/image-upload.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
69
playwright/e2e/forgot-password/forgot-password.spec.ts
Normal file
69
playwright/e2e/forgot-password/forgot-password.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
120
playwright/e2e/integration-manager/get-openid-token.spec.ts
Normal file
120
playwright/e2e/integration-manager/get-openid-token.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
218
playwright/e2e/integration-manager/kick.spec.ts
Normal file
218
playwright/e2e/integration-manager/kick.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
225
playwright/e2e/integration-manager/read_events.spec.ts
Normal file
225
playwright/e2e/integration-manager/read_events.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
247
playwright/e2e/integration-manager/send_event.spec.ts
Normal file
247
playwright/e2e/integration-manager/send_event.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
16
playwright/e2e/integration-manager/utils.ts
Normal file
16
playwright/e2e/integration-manager/utils.ts
Normal 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();
|
||||
}
|
124
playwright/e2e/invite/invite-dialog.spec.ts
Normal file
124
playwright/e2e/invite/invite-dialog.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
84
playwright/e2e/knock/create-knock-room.spec.ts
Normal file
84
playwright/e2e/knock/create-knock-room.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
294
playwright/e2e/knock/knock-into-room.spec.ts
Normal file
294
playwright/e2e/knock/knock-into-room.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
110
playwright/e2e/knock/manage-knocks.spec.ts
Normal file
110
playwright/e2e/knock/manage-knocks.spec.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
130
playwright/e2e/lazy-loading/lazy-loading.spec.ts
Normal file
130
playwright/e2e/lazy-loading/lazy-loading.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
23
playwright/e2e/left-panel/left-panel.spec.ts
Normal file
23
playwright/e2e/left-panel/left-panel.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
59
playwright/e2e/location/location.spec.ts
Normal file
59
playwright/e2e/location/location.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
45
playwright/e2e/login/consent.spec.ts
Normal file
45
playwright/e2e/login/consent.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
288
playwright/e2e/login/login.spec.ts
Normal file
288
playwright/e2e/login/login.spec.ts
Normal 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\/$/);
|
||||
});
|
||||
});
|
||||
});
|
46
playwright/e2e/login/overwrite_login.spec.ts
Normal file
46
playwright/e2e/login/overwrite_login.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
122
playwright/e2e/login/soft_logout.spec.ts
Normal file
122
playwright/e2e/login/soft_logout.spec.ts
Normal 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;
|
||||
}
|
60
playwright/e2e/login/utils.ts
Normal file
60
playwright/e2e/login/utils.ts
Normal 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",
|
||||
}));
|
||||
}
|
178
playwright/e2e/messages/messages.spec.ts
Normal file
178
playwright/e2e/messages/messages.spec.ts
Normal 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")],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
96
playwright/e2e/oidc/index.ts
Normal file
96
playwright/e2e/oidc/index.ts
Normal 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();
|
||||
}
|
34
playwright/e2e/oidc/oidc-aware.spec.ts
Normal file
34
playwright/e2e/oidc/oidc-aware.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
68
playwright/e2e/oidc/oidc-native.spec.ts
Normal file
68
playwright/e2e/oidc/oidc-native.spec.ts
Normal 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;
|
||||
});
|
||||
});
|
46
playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts
Normal file
46
playwright/e2e/one-to-one-chat/one-to-one-chat.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
102
playwright/e2e/permalinks/permalinks.spec.ts
Normal file
102
playwright/e2e/permalinks/permalinks.spec.ts
Normal 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"),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
276
playwright/e2e/pinned-messages/index.ts
Normal file
276
playwright/e2e/pinned-messages/index.ts
Normal 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 };
|
154
playwright/e2e/pinned-messages/pinned-messages.spec.ts
Normal file
154
playwright/e2e/pinned-messages/pinned-messages.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
142
playwright/e2e/polls/pollHistory.spec.ts
Normal file
142
playwright/e2e/polls/pollHistory.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
325
playwright/e2e/polls/polls.spec.ts
Normal file
325
playwright/e2e/polls/polls.spec.ts
Normal 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"));
|
||||
});
|
||||
});
|
60
playwright/e2e/presence/presence.spec.ts
Normal file
60
playwright/e2e/presence/presence.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
183
playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts
Normal file
183
playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
338
playwright/e2e/read-receipts/high-level.spec.ts
Normal file
338
playwright/e2e/read-receipts/high-level.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
648
playwright/e2e/read-receipts/index.ts
Normal file
648
playwright/e2e/read-receipts/index.ts
Normal 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 };
|
84
playwright/e2e/read-receipts/message-ordering.spec.ts
Normal file
84
playwright/e2e/read-receipts/message-ordering.spec.ts
Normal 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",
|
||||
() => {},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
51
playwright/e2e/read-receipts/missing-referents.spec.ts
Normal file
51
playwright/e2e/read-receipts/missing-referents.spec.ts
Normal 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?
|
||||
});
|
||||
});
|
301
playwright/e2e/read-receipts/new-messages-in-threads.spec.ts
Normal file
301
playwright/e2e/read-receipts/new-messages-in-threads.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
160
playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts
Normal file
160
playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
110
playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts
Normal file
110
playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
48
playwright/e2e/read-receipts/notifications.spec.ts
Normal file
48
playwright/e2e/read-receipts/notifications.spec.ts
Normal 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", () => {});
|
||||
});
|
||||
});
|
||||
});
|
201
playwright/e2e/read-receipts/reactions-in-threads.spec.ts
Normal file
201
playwright/e2e/read-receipts/reactions-in-threads.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
91
playwright/e2e/read-receipts/reactions-main-timeline.spec.ts
Normal file
91
playwright/e2e/read-receipts/reactions-main-timeline.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
107
playwright/e2e/read-receipts/reactions-thread-roots.spec.ts
Normal file
107
playwright/e2e/read-receipts/reactions-thread-roots.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
337
playwright/e2e/read-receipts/read-receipts.spec.ts
Normal file
337
playwright/e2e/read-receipts/read-receipts.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
20
playwright/e2e/read-receipts/readme.md
Normal file
20
playwright/e2e/read-receipts/readme.md
Normal 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.
|
554
playwright/e2e/read-receipts/redactions-in-threads.spec.ts
Normal file
554
playwright/e2e/read-receipts/redactions-in-threads.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
323
playwright/e2e/read-receipts/redactions-main-timeline.spec.ts
Normal file
323
playwright/e2e/read-receipts/redactions-main-timeline.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
224
playwright/e2e/read-receipts/redactions-thread-roots.spec.ts
Normal file
224
playwright/e2e/read-receipts/redactions-thread-roots.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
53
playwright/e2e/read-receipts/room-list-order.spec.ts
Normal file
53
playwright/e2e/read-receipts/room-list-order.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
});
|
71
playwright/e2e/register/email.spec.ts
Normal file
71
playwright/e2e/register/email.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
116
playwright/e2e/register/register.spec.ts
Normal file
116
playwright/e2e/register/register.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
52
playwright/e2e/regression-tests/pills-click-in-app.spec.ts
Normal file
52
playwright/e2e/regression-tests/pills-click-in-app.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
67
playwright/e2e/release-announcement/index.ts
Normal file
67
playwright/e2e/release-announcement/index.ts
Normal 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 };
|
|
@ -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");
|
||||
});
|
||||
});
|
214
playwright/e2e/right-panel/file-panel.spec.ts
Normal file
214
playwright/e2e/right-panel/file-panel.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
35
playwright/e2e/right-panel/notification-panel.spec.ts
Normal file
35
playwright/e2e/right-panel/notification-panel.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
143
playwright/e2e/right-panel/right-panel.spec.ts
Normal file
143
playwright/e2e/right-panel/right-panel.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
22
playwright/e2e/right-panel/utils.ts
Normal file
22
playwright/e2e/right-panel/utils.ts
Normal 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);
|
||||
}
|
80
playwright/e2e/room-directory/room-directory.spec.ts
Normal file
80
playwright/e2e/room-directory/room-directory.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
146
playwright/e2e/room/room-header.spec.ts
Normal file
146
playwright/e2e/room/room-header.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
98
playwright/e2e/room/room.spec.ts
Normal file
98
playwright/e2e/room/room.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
53
playwright/e2e/room_options/marked_unread.spec.ts
Normal file
53
playwright/e2e/room_options/marked_unread.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
143
playwright/e2e/settings/account-user-settings-tab.spec.ts
Normal file
143
playwright/e2e/settings/account-user-settings-tab.spec.ts
Normal 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
|
||||
});
|
||||
});
|
|
@ -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", '""');
|
||||
});
|
||||
});
|
233
playwright/e2e/settings/appearance-user-settings-tab/index.ts
Normal file
233
playwright/e2e/settings/appearance-user-settings-tab/index.ts
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
97
playwright/e2e/settings/device-management.spec.ts
Normal file
97
playwright/e2e/settings/device-management.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
55
playwright/e2e/settings/general-room-settings-tab.spec.ts
Normal file
55
playwright/e2e/settings/general-room-settings-tab.spec.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue