From 4c2efc36376fe7ba44b4cb7b458af0ca3a9eb4ef Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Tue, 19 Dec 2023 14:06:54 +0530 Subject: [PATCH] Playwright: Convert lazy-loading test to playwright (#11988) * Implement method to wait for next sync * Add timeline coded to app page * Convert network plugin * Add createBot fixture * Convert lazy-loading test * Remove cypress test * Remove converted files * Remove imports * Fix date in copyright header Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> * Fix date in copyright header Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> * Use proper method to send messages * Fix sliding-sync test * Address comments * Move code to timeline --------- Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- cypress/e2e/lazy-loading/lazy-loading.spec.ts | 184 ------------------ cypress/support/e2e.ts | 2 - cypress/support/network.ts | 72 ------- cypress/support/timeline.ts | 70 ------- .../e2e/lazy-loading/lazy-loading.spec.ts | 137 +++++++++++++ .../e2e/sliding-sync/sliding-sync.spec.ts | 14 +- playwright/e2e/timeline/timeline.spec.ts | 16 +- playwright/pages/ElementAppPage.ts | 8 +- playwright/pages/client.ts | 23 ++- playwright/pages/network.ts | 59 ++++++ playwright/pages/timeline.ts | 52 +++++ 11 files changed, 279 insertions(+), 358 deletions(-) delete mode 100644 cypress/e2e/lazy-loading/lazy-loading.spec.ts delete mode 100644 cypress/support/network.ts delete mode 100644 cypress/support/timeline.ts create mode 100644 playwright/e2e/lazy-loading/lazy-loading.spec.ts create mode 100644 playwright/pages/network.ts create mode 100644 playwright/pages/timeline.ts 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; + } + } + } +}