diff --git a/cypress/e2e/lazy-loading/lazy-loading.spec.ts b/cypress/e2e/lazy-loading/lazy-loading.spec.ts
deleted file mode 100644
index 9b5f0eb670..0000000000
--- a/cypress/e2e/lazy-loading/lazy-loading.spec.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-/*
-Copyright 2022 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-///
-
-import { HomeserverInstance } from "../../plugins/utils/homeserver";
-import { MatrixClient } from "../../global";
-import Chainable = Cypress.Chainable;
-
-interface Charly {
- client: MatrixClient;
- displayName: string;
-}
-
-describe("Lazy Loading", () => {
- let homeserver: HomeserverInstance;
- let bob: MatrixClient;
- const charlies: Charly[] = [];
-
- beforeEach(() => {
- cy.window().then((win) => {
- win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests
- });
-
- cy.startHomeserver("default").then((data) => {
- homeserver = data;
-
- cy.initTestUser(homeserver, "Alice");
-
- cy.getBot(homeserver, {
- displayName: "Bob",
- startClient: false,
- autoAcceptInvites: false,
- }).then((_bob) => {
- bob = _bob;
- });
-
- for (let i = 1; i <= 10; i++) {
- const displayName = `Charly #${i}`;
- cy.getBot(homeserver, {
- displayName,
- startClient: false,
- autoAcceptInvites: false,
- }).then((client) => {
- charlies[i - 1] = { displayName, client };
- });
- }
- });
- });
-
- afterEach(() => {
- cy.stopHomeserver(homeserver);
- });
-
- const name = "Lazy Loading Test";
- const alias = "#lltest:localhost";
- const charlyMsg1 = "hi bob!";
- const charlyMsg2 = "how's it going??";
-
- function setupRoomWithBobAliceAndCharlies(charlies: Charly[]) {
- cy.window({ log: false }).then((win) => {
- return cy
- .wrap(
- bob
- .createRoom({
- name,
- room_alias_name: "lltest",
- visibility: win.matrixcs.Visibility.Public,
- })
- .then((r) => r.room_id),
- { log: false },
- )
- .as("roomId");
- });
-
- cy.get("@roomId").then(async (roomId) => {
- for (const charly of charlies) {
- await charly.client.joinRoom(alias);
- }
-
- for (const charly of charlies) {
- cy.botSendMessage(charly.client, roomId, charlyMsg1);
- }
- for (const charly of charlies) {
- cy.botSendMessage(charly.client, roomId, charlyMsg2);
- }
-
- for (let i = 20; i >= 1; --i) {
- cy.botSendMessage(bob, roomId, `I will only say this ${i} time(s)!`);
- }
- });
-
- cy.joinRoom(alias);
- cy.viewRoomByName(name);
- }
-
- function checkPaginatedDisplayNames(charlies: Charly[]) {
- cy.scrollToTop();
- for (const charly of charlies) {
- cy.findEventTile(charly.displayName, charlyMsg1).should("exist");
- cy.findEventTile(charly.displayName, charlyMsg2).should("exist");
- }
- }
-
- function openMemberlist(): void {
- cy.get(".mx_LegacyRoomHeader").within(() => {
- cy.findByRole("button", { name: "Room info" }).click();
- });
-
- cy.get(".mx_RoomSummaryCard").within(() => {
- cy.findByRole("menuitem", { name: "People" }).click(); // \d represents the number of the room members
- });
- }
-
- function getMemberInMemberlist(name: string): Chainable {
- return cy.contains(".mx_MemberList .mx_EntityTile_name", name);
- }
-
- function checkMemberList(charlies: Charly[]) {
- getMemberInMemberlist("Alice").should("exist");
- getMemberInMemberlist("Bob").should("exist");
- charlies.forEach((charly) => {
- getMemberInMemberlist(charly.displayName).should("exist");
- });
- }
-
- function checkMemberListLacksCharlies(charlies: Charly[]) {
- charlies.forEach((charly) => {
- getMemberInMemberlist(charly.displayName).should("not.exist");
- });
- }
-
- function joinCharliesWhileAliceIsOffline(charlies: Charly[]) {
- cy.goOffline();
-
- cy.get("@roomId").then(async (roomId) => {
- for (const charly of charlies) {
- await charly.client.joinRoom(alias);
- }
- for (let i = 20; i >= 1; --i) {
- cy.botSendMessage(charlies[0].client, roomId, "where is charly?");
- }
- });
-
- cy.goOnline();
- cy.wait(1000); // Ideally we'd await a /sync here but intercepts step on each other from going offline/online
- }
-
- it("should handle lazy loading properly even when offline", () => {
- const charly1to5 = charlies.slice(0, 5);
- const charly6to10 = charlies.slice(5);
-
- // Set up room with alice, bob & charlies 1-5
- setupRoomWithBobAliceAndCharlies(charly1to5);
- // Alice should see 2 messages from every charly with the correct display name
- checkPaginatedDisplayNames(charly1to5);
-
- openMemberlist();
- checkMemberList(charly1to5);
- joinCharliesWhileAliceIsOffline(charly6to10);
- checkMemberList(charly6to10);
-
- cy.get("@roomId").then(async (roomId) => {
- for (const charly of charlies) {
- await charly.client.leave(roomId);
- }
- });
-
- checkMemberListLacksCharlies(charlies);
- });
-});
diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts
index cce1534cd9..084ac73ed8 100644
--- a/cypress/support/e2e.ts
+++ b/cypress/support/e2e.ts
@@ -35,8 +35,6 @@ import "./percy";
import "./webserver";
import "./views";
import "./iframes";
-import "./timeline";
-import "./network";
import "./composer";
import "./axe";
import "./promise";
diff --git a/cypress/support/network.ts b/cypress/support/network.ts
deleted file mode 100644
index 3e031099fb..0000000000
--- a/cypress/support/network.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
-Copyright 2022 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-///
-
-declare global {
- // eslint-disable-next-line @typescript-eslint/no-namespace
- namespace Cypress {
- interface Chainable {
- // Intercept all /_matrix/ networking requests for the logged-in user and fail them
- goOffline(): void;
- // Remove intercept on all /_matrix/ networking requests
- goOnline(): void;
- // Intercept calls to vector.im/matrix.org so a login page can be shown offline
- stubDefaultServer(): void;
- }
- }
-}
-
-// We manage intercepting Matrix APIs here, as fully disabling networking will disconnect
-// the browser under test from the Cypress runner, so can cause issues.
-
-Cypress.Commands.add("goOffline", (): void => {
- cy.log("Going offline");
- cy.window({ log: false }).then((win) => {
- cy.intercept(
- "**/_matrix/**",
- {
- headers: {
- Authorization: "Bearer " + win.mxMatrixClientPeg.matrixClient.getAccessToken(),
- },
- },
- (req) => {
- req.destroy();
- },
- );
- });
-});
-
-Cypress.Commands.add("goOnline", (): void => {
- cy.log("Going online");
- cy.window({ log: false }).then((win) => {
- cy.intercept(
- "**/_matrix/**",
- {
- headers: {
- Authorization: "Bearer " + win.mxMatrixClientPeg.matrixClient.getAccessToken(),
- },
- },
- (req) => {
- req.continue();
- },
- );
- win.dispatchEvent(new Event("online"));
- });
-});
-
-// Needed to make this file a module
-export {};
diff --git a/cypress/support/timeline.ts b/cypress/support/timeline.ts
deleted file mode 100644
index 1c4bcdc05f..0000000000
--- a/cypress/support/timeline.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
-Copyright 2022 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-///
-
-import Chainable = Cypress.Chainable;
-
-declare global {
- // eslint-disable-next-line @typescript-eslint/no-namespace
- namespace Cypress {
- interface Chainable {
- // Scroll to the top of the timeline
- scrollToTop(): void;
- // Find the event tile matching the given sender & body
- findEventTile(sender: string, body: string): Chainable;
- }
- }
-}
-
-export interface Message {
- sender: string;
- body: string;
- encrypted: boolean;
- continuation: boolean;
-}
-
-Cypress.Commands.add("scrollToTop", (): void => {
- cy.get(".mx_RoomView_timeline .mx_ScrollPanel")
- .scrollTo("top", { duration: 100 })
- .then((ref) => {
- if (ref.scrollTop() > 0) {
- return cy.scrollToTop();
- }
- });
-});
-
-Cypress.Commands.add("findEventTile", (sender: string, body: string): Chainable => {
- // We can't just use a bunch of `.contains` here due to continuations meaning that the events don't
- // have their own rendered sender displayname so we have to walk the list to keep track of the sender.
- return cy.get(".mx_RoomView_MessageList .mx_EventTile").then((refs) => {
- let latestSender: string;
- for (let i = 0; i < refs.length; i++) {
- const ref = refs.eq(i);
- const displayName = ref.find(".mx_DisambiguatedProfile_displayName");
- if (displayName) {
- latestSender = displayName.text();
- }
-
- if (latestSender === sender && ref.find(".mx_EventTile_body").text() === body) {
- return ref;
- }
- }
- });
-});
-
-// Needed to make this file a module
-export {};
diff --git a/playwright/e2e/lazy-loading/lazy-loading.spec.ts b/playwright/e2e/lazy-loading/lazy-loading.spec.ts
new file mode 100644
index 0000000000..8b81589813
--- /dev/null
+++ b/playwright/e2e/lazy-loading/lazy-loading.spec.ts
@@ -0,0 +1,137 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { 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(page: Page): Promise {
+ await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Room info" }).click();
+ await page.locator(".mx_RoomSummaryCard").getByRole("menuitem", { name: "People" }).click(); // \d represents the number of the room members
+ }
+
+ 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(page);
+ 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);
+ });
+});
diff --git a/playwright/e2e/sliding-sync/sliding-sync.spec.ts b/playwright/e2e/sliding-sync/sliding-sync.spec.ts
index 1a9fa08d29..e1efa7ec6f 100644
--- a/playwright/e2e/sliding-sync/sliding-sync.spec.ts
+++ b/playwright/e2e/sliding-sync/sliding-sync.spec.ts
@@ -134,7 +134,7 @@ test.describe("Sliding Sync", () => {
const bob = await createAndJoinBot(app, bot);
// send a message in the test room: unread notification count should increment
- await bob.sendTextMessage(roomId, "Hello World");
+ await bob.sendMessage(roomId, "Hello World");
const treeItemLocator1 = page.getByRole("treeitem", { name: "Test Room 1 unread message." });
await expect(treeItemLocator1.locator(".mx_NotificationBadge_count")).toHaveText("1");
@@ -144,7 +144,7 @@ test.describe("Sliding Sync", () => {
);
// send an @mention: highlight count (red) should be 2.
- await bob.sendTextMessage(roomId, `Hello ${user.displayName}`);
+ await bob.sendMessage(roomId, `Hello ${user.displayName}`);
const treeItemLocator2 = page.getByRole("treeitem", {
name: "Test Room 2 unread messages including mentions.",
});
@@ -173,7 +173,7 @@ test.describe("Sliding Sync", () => {
await checkOrder(["Dummy", "Test Room"], page);
- await bot.sendTextMessage(roomId, "Do you read me?");
+ await bot.sendMessage(roomId, "Do you read me?");
// wait for this message to arrive, tell by the room list resorting
await checkOrder(["Test Room", "Dummy"], page);
@@ -273,7 +273,7 @@ test.describe("Sliding Sync", () => {
test.skip("should clear the reply to field when swapping rooms", async ({ page, app }) => {
await app.client.createRoom({ name: "Other Room" });
await expect(page.getByRole("treeitem", { name: "Other Room" })).toBeVisible();
- await app.client.sendTextMessage(roomId, "Hello world");
+ await app.client.sendMessage(roomId, "Hello world");
// select the room
await page.getByRole("treeitem", { name: "Test Room" }).click();
@@ -304,9 +304,9 @@ test.describe("Sliding Sync", () => {
// Regression test for https://github.com/vector-im/element-web/issues/21462
test.skip("should not cancel replies when permalinks are clicked", async ({ page, app }) => {
// we require a first message as you cannot click the permalink text with the avatar in the way
- await app.client.sendTextMessage(roomId, "First message");
- await app.client.sendTextMessage(roomId, "Permalink me");
- await app.client.sendTextMessage(roomId, "Reply to me");
+ await app.client.sendMessage(roomId, "First message");
+ await app.client.sendMessage(roomId, "Permalink me");
+ await app.client.sendMessage(roomId, "Reply to me");
// select the room
await page.getByRole("treeitem", { name: "Test Room" }).click();
diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts
index 5e1102c09b..d7d659f45f 100644
--- a/playwright/e2e/timeline/timeline.spec.ts
+++ b/playwright/e2e/timeline/timeline.spec.ts
@@ -498,7 +498,7 @@ test.describe("Timeline", () => {
.getByText(`${OLD_NAME} created and configured the room.`),
).toBeVisible();
- await app.scrollToBottom(page);
+ await app.timeline.scrollToBottom();
await expect(
page.locator(".mx_RoomView").getByText("This message has an inline emoji 👒"),
).toBeInViewport();
@@ -514,7 +514,7 @@ test.describe("Timeline", () => {
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
// Check that the last EventTile is rendered
- await app.scrollToBottom(page);
+ await app.timeline.scrollToBottom();
await expect(
page.locator(".mx_RoomView").getByText("This message has an inline emoji 👒"),
).toBeInViewport();
@@ -527,7 +527,7 @@ test.describe("Timeline", () => {
await app.settings.setValue("useCompactLayout", null, SettingLevel.DEVICE, true);
// Check that the last EventTile is rendered
- await app.scrollToBottom(page);
+ await app.timeline.scrollToBottom();
await expect(
page.locator(".mx_RoomView").getByText("This message has an inline emoji 👒"),
).toBeInViewport();
@@ -542,7 +542,7 @@ test.describe("Timeline", () => {
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
- await app.scrollToBottom(page);
+ await app.timeline.scrollToBottom();
await expect(
page.locator(".mx_RoomView").getByText("This message has an inline emoji 👒"),
).toBeInViewport();
@@ -741,7 +741,7 @@ test.describe("Timeline", () => {
await checkA11y();
- await app.scrollToBottom(page);
+ await app.timeline.scrollToBottom();
await expect(page.locator(".mx_EventTile_last")).toMatchScreenshot("url-preview.png", {
// Exclude timestamp and read marker from snapshot
mask: [page.locator(".mx_MessageTimestamp")],
@@ -1090,7 +1090,7 @@ test.describe("Timeline", () => {
// Make sure the strings do not overflow on IRC layout
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
// Scroll to the bottom to have Percy take a snapshot of the whole viewport
- await app.scrollToBottom(page);
+ await app.timeline.scrollToBottom();
// Assert that both avatar in the introduction and the last message are visible at the same time
await expect(page.locator(".mx_NewRoomIntro .mx_BaseAvatar")).toBeVisible();
const lastEventTileIrc = page.locator(".mx_EventTile_last[data-layout='irc']");
@@ -1104,7 +1104,7 @@ test.describe("Timeline", () => {
// Make sure the strings do not overflow on modern layout
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group);
- await app.scrollToBottom(page); // Scroll again in case
+ await app.timeline.scrollToBottom(); // Scroll again in case
await expect(page.locator(".mx_NewRoomIntro .mx_BaseAvatar")).toBeVisible();
const lastEventTileGroup = page.locator(".mx_EventTile_last[data-layout='group']");
await expect(lastEventTileGroup.locator(".mx_MTextBody").first()).toBeVisible();
@@ -1116,7 +1116,7 @@ test.describe("Timeline", () => {
// Make sure the strings do not overflow on bubble layout
await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble);
- await app.scrollToBottom(page); // Scroll again in case
+ await app.timeline.scrollToBottom(); // Scroll again in case
await expect(page.locator(".mx_NewRoomIntro .mx_BaseAvatar")).toBeVisible();
const lastEventTileBubble = page.locator(".mx_EventTile_last[data-layout='bubble']");
await expect(lastEventTileBubble.locator(".mx_MTextBody").first()).toBeVisible();
diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts
index 1efc98d497..31170f81f4 100644
--- a/playwright/pages/ElementAppPage.ts
+++ b/playwright/pages/ElementAppPage.ts
@@ -18,6 +18,7 @@ import { type Locator, type Page, expect } from "@playwright/test";
import { Settings } from "./settings";
import { Client } from "./client";
+import { Timeline } from "./timeline";
import { Spotlight } from "./Spotlight";
export class ElementAppPage {
@@ -25,6 +26,7 @@ export class ElementAppPage {
public settings = new Settings(this.page);
public client: Client = new Client(this.page);
+ public timeline: Timeline = new Timeline(this.page);
/**
* Open the top left user menu, returning a Locator to the resulting context menu.
@@ -161,10 +163,4 @@ export class ElementAppPage {
await spotlight.open();
return spotlight;
}
-
- public async scrollToBottom(page: Page): Promise {
- await page
- .locator(".mx_ScrollPanel")
- .evaluate((scrollPanel) => scrollPanel.scrollTo(0, scrollPanel.scrollHeight));
- }
}
diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts
index 7f9180ca2e..e73e101e3d 100644
--- a/playwright/pages/client.ts
+++ b/playwright/pages/client.ts
@@ -17,6 +17,7 @@ limitations under the License.
import { JSHandle, Page } from "@playwright/test";
import { PageFunctionOn } from "playwright-core/types/structs";
+import { Network } from "./network";
import type {
IContent,
ICreateRoomOpts,
@@ -34,6 +35,7 @@ import type {
import { Credentials } from "../plugins/homeserver";
export class Client {
+ public network: Network;
protected client: JSHandle;
protected getClientHandle(): Promise> {
@@ -51,6 +53,7 @@ export class Client {
page.on("framenavigated", async () => {
this.client = null;
});
+ this.network = new Network(page, this);
}
public evaluate(
@@ -134,15 +137,6 @@ export class Client {
);
}
- /**
- * Send a text message into a room
- * @param roomId ID of the room to send the message into
- * @param content the event content to send
- */
- public async sendTextMessage(roomId: string, message: string): Promise {
- return await this.sendMessage(roomId, { msgtype: "m.text", body: message });
- }
-
/**
* Create a room with given options.
* @param options the options to apply when creating the room
@@ -215,6 +209,17 @@ export class Client {
);
}
+ /**
+ * Wait until next sync from this client
+ */
+ public async waitForNextSync(): Promise {
+ await this.page.waitForResponse(async (response) => {
+ const accessToken = await this.evaluate((client) => client.getAccessToken());
+ const authHeader = await response.request().headerValue("authorization");
+ return response.url().includes("/sync") && authHeader.includes(accessToken);
+ });
+ }
+
/**
* Invites the given user to the given room.
* @param roomId the id of the room to invite to
diff --git a/playwright/pages/network.ts b/playwright/pages/network.ts
new file mode 100644
index 0000000000..cfc23353ec
--- /dev/null
+++ b/playwright/pages/network.ts
@@ -0,0 +1,59 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import type { Page, Request } from "@playwright/test";
+import type { Client } from "./client";
+
+export class Network {
+ private isOffline = false;
+ private readonly setupPromise: Promise;
+
+ constructor(private page: Page, private client: Client) {
+ this.setupPromise = this.setupRoute();
+ }
+
+ /**
+ * Checks if the request is from the client associated with this network object.
+ * We do this so that other clients (eg: bots) are not affected by the network change.
+ */
+ private async isRequestFromOurClient(request: Request): Promise {
+ const accessToken = await this.client.evaluate((client) => client.getAccessToken());
+ const authHeader = await request.headerValue("Authorization");
+ return authHeader === `Bearer ${accessToken}`;
+ }
+
+ private async setupRoute() {
+ await this.page.route("**/_matrix/**", async (route) => {
+ if (this.isOffline && (await this.isRequestFromOurClient(route.request()))) {
+ route.abort();
+ } else {
+ route.continue();
+ }
+ });
+ }
+
+ // Intercept all /_matrix/ networking requests for client and fail them
+ async goOffline(): Promise {
+ await this.setupPromise;
+ this.isOffline = true;
+ }
+
+ // Remove intercept on all /_matrix/ networking requests for this client
+ async goOnline(): Promise {
+ await this.setupPromise;
+ this.isOffline = false;
+ }
+}
diff --git a/playwright/pages/timeline.ts b/playwright/pages/timeline.ts
new file mode 100644
index 0000000000..de9a9a58ec
--- /dev/null
+++ b/playwright/pages/timeline.ts
@@ -0,0 +1,52 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import type { Locator, Page } from "@playwright/test";
+
+export class Timeline {
+ constructor(private page: Page) {}
+
+ // Scroll to the top of the timeline
+ async scrollToTop(): Promise {
+ const locator = this.page.locator(".mx_RoomView_timeline .mx_ScrollPanel");
+ await locator.evaluate((node) => {
+ while (node.scrollTop > 0) {
+ node.scrollTo(0, 0);
+ }
+ });
+ }
+
+ public async scrollToBottom(): Promise {
+ await this.page
+ .locator(".mx_ScrollPanel")
+ .evaluate((scrollPanel) => scrollPanel.scrollTo(0, scrollPanel.scrollHeight));
+ }
+
+ // Find the event tile matching the given sender & body
+ async findEventTile(sender: string, body: string): Promise {
+ const locators = await this.page.locator(".mx_RoomView_MessageList .mx_EventTile").all();
+ let latestSender: string;
+ for (const locator of locators) {
+ const displayName = locator.locator(".mx_DisambiguatedProfile_displayName");
+ if (await displayName.count()) {
+ latestSender = await displayName.innerText();
+ }
+ if (latestSender === sender && (await locator.locator(".mx_EventTile_body").innerText()) === body) {
+ return locator;
+ }
+ }
+ }
+}