From 7b3d5b5f217f3e23c5fea3e8030c96ceac748f8e Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Sat, 16 Dec 2023 15:31:26 +0530 Subject: [PATCH] Playwright: Convert sliding-sync test to playwright (#11989) * Add method to send text message * Add dockerUrl to HomeServerConfig * Implement sliding sync proxy * Convert tests * Reload page after applying labs feature * Remove converted files * Remove timeout * Remove sliding-sync * Remove proxy import * Remove reference to proxy * wait for load * Update date * Convert enableLabsFeature to separate fixture * Enable feature in fixture * Skip over config and just write to local-storage * Rename fixture * Fix room header test * Use type inference * Override config instead of setting localstorage * Set default language * Always add labs feature * Put this one test into a separate describe block * Move labs lag within describe block --- cypress/e2e/sliding-sync/sliding-sync.spec.ts | 502 ------------------ cypress/plugins/index.ts | 3 +- cypress/plugins/sliding-sync/index.ts | 129 ----- cypress/support/e2e.ts | 1 - cypress/support/proxy.ts | 58 -- .../right-panel/notification-panel.spec.ts | 2 +- playwright/e2e/room/room-header.spec.ts | 62 ++- .../e2e/sliding-sync/sliding-sync.spec.ts | 375 +++++++++++++ playwright/element-web-test.ts | 23 +- playwright/pages/ElementAppPage.ts | 2 - playwright/pages/client.ts | 11 +- playwright/pages/labs.ts | 37 -- .../plugins/homeserver/dendrite/index.ts | 7 +- playwright/plugins/homeserver/index.ts | 1 + .../plugins/homeserver/synapse/index.ts | 5 +- .../plugins/sliding-sync-proxy/index.ts | 99 ++++ 16 files changed, 547 insertions(+), 770 deletions(-) delete mode 100644 cypress/e2e/sliding-sync/sliding-sync.spec.ts delete mode 100644 cypress/plugins/sliding-sync/index.ts delete mode 100644 cypress/support/proxy.ts create mode 100644 playwright/e2e/sliding-sync/sliding-sync.spec.ts delete mode 100644 playwright/pages/labs.ts create mode 100644 playwright/plugins/sliding-sync-proxy/index.ts diff --git a/cypress/e2e/sliding-sync/sliding-sync.spec.ts b/cypress/e2e/sliding-sync/sliding-sync.spec.ts deleted file mode 100644 index ee9beafe14..0000000000 --- a/cypress/e2e/sliding-sync/sliding-sync.spec.ts +++ /dev/null @@ -1,502 +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 _ from "lodash"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { Interception } from "cypress/types/net-stubbing"; - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { ProxyInstance } from "../../plugins/sliding-sync"; - -describe("Sliding Sync", () => { - beforeEach(() => { - cy.startHomeserver("default") - .as("homeserver") - .then((homeserver) => { - cy.startProxy(homeserver).as("proxy"); - }); - - cy.all([cy.get("@homeserver"), cy.get("@proxy")]).then( - ([homeserver, proxy]) => { - cy.enableLabsFeature("feature_sliding_sync"); - - cy.intercept("/config.json?cachebuster=*", (req) => { - return req.continue((res) => { - res.send(200, { - ...res.body, - setting_defaults: { - feature_sliding_sync_proxy_url: `http://localhost:${proxy.port}`, - }, - }); - }); - }); - - cy.initTestUser(homeserver, "Sloth").then(() => { - return cy.window({ log: false }).then(() => { - cy.createRoom({ name: "Test Room" }).as("roomId"); - }); - }); - }, - ); - }); - - afterEach(() => { - cy.get("@homeserver").then(cy.stopHomeserver); - cy.get("@proxy").then(cy.stopProxy); - }); - - // assert order - const checkOrder = (wantOrder: string[]) => { - cy.findByRole("group", { name: "Rooms" }) - .find(".mx_RoomTile_title") - .should((elements) => { - expect( - _.map(elements, (e) => { - return e.textContent; - }), - "rooms are sorted", - ).to.deep.equal(wantOrder); - }); - }; - const bumpRoom = (alias: string) => { - // Send a message into the given room, this should bump the room to the top - cy.get(alias).then((roomId) => { - return cy.sendEvent(roomId, null, "m.room.message", { - body: "Hello world", - msgtype: "m.text", - }); - }); - }; - const createAndJoinBob = () => { - // create a Bob user - cy.get("@homeserver").then((homeserver) => { - return cy - .getBot(homeserver, { - displayName: "Bob", - }) - .as("bob"); - }); - - // invite Bob to Test Room and accept then send a message. - cy.all([cy.get("@roomId"), cy.get("@bob")]).then(([roomId, bob]) => { - return cy.inviteUser(roomId, bob.getUserId()).then(() => { - return bob.joinRoom(roomId); - }); - }); - }; - - it.skip("should render the Rooms list in reverse chronological order by default and allowing sorting A-Z", () => { - // create rooms and check room names are correct - cy.createRoom({ name: "Apple" }).then(() => cy.findByRole("treeitem", { name: "Apple" })); - cy.createRoom({ name: "Pineapple" }).then(() => cy.findByRole("treeitem", { name: "Pineapple" })); - cy.createRoom({ name: "Orange" }).then(() => cy.findByRole("treeitem", { name: "Orange" })); - - cy.get(".mx_RoomSublist_tiles").within(() => { - cy.findAllByRole("treeitem").should("have.length", 4); // due to the Test Room in beforeEach - }); - - checkOrder(["Orange", "Pineapple", "Apple", "Test Room"]); - - cy.findByRole("group", { name: "Rooms" }).within(() => { - cy.get(".mx_RoomSublist_headerContainer") - .realHover() - .findByRole("button", { name: "List options" }) - .click(); - }); - - // force click as the radio button's size is zero - cy.findByRole("menuitemradio", { name: "A-Z" }).click({ force: true }); - - // Assert that the radio button is checked - cy.get(".mx_StyledRadioButton_checked").within(() => { - cy.findByText("A-Z").should("exist"); - }); - - checkOrder(["Apple", "Orange", "Pineapple", "Test Room"]); - }); - - it.skip("should move rooms around as new events arrive", () => { - // create rooms and check room names are correct - cy.createRoom({ name: "Apple" }) - .as("roomA") - .then(() => cy.findByRole("treeitem", { name: "Apple" })); - cy.createRoom({ name: "Pineapple" }) - .as("roomP") - .then(() => cy.findByRole("treeitem", { name: "Pineapple" })); - cy.createRoom({ name: "Orange" }) - .as("roomO") - .then(() => cy.findByRole("treeitem", { name: "Orange" })); - - // Select the Test Room - cy.findByRole("treeitem", { name: "Test Room" }).click(); - - checkOrder(["Orange", "Pineapple", "Apple", "Test Room"]); - bumpRoom("@roomA"); - checkOrder(["Apple", "Orange", "Pineapple", "Test Room"]); - bumpRoom("@roomO"); - checkOrder(["Orange", "Apple", "Pineapple", "Test Room"]); - bumpRoom("@roomO"); - checkOrder(["Orange", "Apple", "Pineapple", "Test Room"]); - bumpRoom("@roomP"); - checkOrder(["Pineapple", "Orange", "Apple", "Test Room"]); - }); - - it.skip("should not move the selected room: it should be sticky", () => { - // create rooms and check room names are correct - cy.createRoom({ name: "Apple" }) - .as("roomA") - .then(() => cy.findByRole("treeitem", { name: "Apple" })); - cy.createRoom({ name: "Pineapple" }) - .as("roomP") - .then(() => cy.findByRole("treeitem", { name: "Pineapple" })); - cy.createRoom({ name: "Orange" }) - .as("roomO") - .then(() => cy.findByRole("treeitem", { name: "Orange" })); - - // Given a list of Orange, Pineapple, Apple - if Pineapple is active and a message is sent in Apple, the list should - // turn into Apple, Pineapple, Orange - the index position of Pineapple never changes even though the list should technically - // be Apple, Orange Pineapple - only when you click on a different room do things reshuffle. - - // Select the Pineapple room - cy.findByRole("treeitem", { name: "Pineapple" }).click(); - checkOrder(["Orange", "Pineapple", "Apple", "Test Room"]); - - // Move Apple - bumpRoom("@roomA"); - checkOrder(["Apple", "Pineapple", "Orange", "Test Room"]); - - // Select the Test Room - cy.findByRole("treeitem", { name: "Test Room" }).click(); - - // the rooms reshuffle to match reality - checkOrder(["Apple", "Orange", "Pineapple", "Test Room"]); - }); - - it.skip("should show the right unread notifications", () => { - createAndJoinBob(); - - // send a message in the test room: unread notif count shoould increment - cy.all([cy.get("@roomId"), cy.get("@bob")]).then(([roomId, bob]) => { - return bob.sendTextMessage(roomId, "Hello World"); - }); - - // check that there is an unread notification (grey) as 1 - cy.findByRole("treeitem", { name: "Test Room 1 unread message." }).contains(".mx_NotificationBadge_count", "1"); - cy.get(".mx_NotificationBadge").should("not.have.class", "mx_NotificationBadge_highlighted"); - - // send an @mention: highlight count (red) should be 2. - cy.all([cy.get("@roomId"), cy.get("@bob")]).then(([roomId, bob]) => { - return bob.sendTextMessage(roomId, "Hello Sloth"); - }); - cy.findByRole("treeitem", { name: "Test Room 2 unread messages including mentions." }).contains( - ".mx_NotificationBadge_count", - "2", - ); - cy.get(".mx_NotificationBadge").should("have.class", "mx_NotificationBadge_highlighted"); - - // click on the room, the notif counts should disappear - cy.findByRole("treeitem", { name: "Test Room 2 unread messages including mentions." }).click(); - cy.findByRole("treeitem", { name: "Test Room" }).should("not.have.class", "mx_NotificationBadge_count"); - }); - - it.skip("should not show unread indicators", () => { - // TODO: for now. Later we should. - createAndJoinBob(); - - // disable notifs in this room (TODO: CS API call?) - cy.findByRole("treeitem", { name: "Test Room" }) - .realHover() - .findByRole("button", { name: "Notification options" }) - .click(); - cy.findByRole("menuitemradio", { name: "Mute room" }).click(); - - // create a new room so we know when the message has been received as it'll re-shuffle the room list - cy.createRoom({ - name: "Dummy", - }); - checkOrder(["Dummy", "Test Room"]); - - cy.all([cy.get("@roomId"), cy.get("@bob")]).then(([roomId, bob]) => { - return bob.sendTextMessage(roomId, "Do you read me?"); - }); - // wait for this message to arrive, tell by the room list resorting - checkOrder(["Test Room", "Dummy"]); - - cy.findByRole("treeitem", { name: "Test Room" }).get(".mx_NotificationBadge").should("not.exist"); - }); - - it("should update user settings promptly", () => { - cy.openUserSettings("Preferences"); - cy.contains(".mx_SettingsFlag", "Show timestamps in 12 hour format") - .should("exist") - .find(".mx_ToggleSwitch_on") - .should("not.exist"); - cy.contains(".mx_SettingsFlag", "Show timestamps in 12 hour format") - .should("exist") - .find(".mx_ToggleSwitch_ball") - .click(); - cy.contains(".mx_SettingsFlag", "Show timestamps in 12 hour format", { timeout: 2000 }) - .should("exist") - .find(".mx_ToggleSwitch_on", { timeout: 2000 }) - .should("exist"); - }); - - it.skip("should show and be able to accept/reject/rescind invites", () => { - createAndJoinBob(); - - let clientUserId; - cy.getClient().then((cli) => { - clientUserId = cli.getUserId(); - }); - - // invite Sloth into 3 rooms: - // - roomJoin: will join this room - // - roomReject: will reject the invite - // - roomRescind: will make Bob rescind the invite - let roomJoin; - let roomReject; - let roomRescind; - let bobClient; - cy.get("@bob") - .then((bob) => { - bobClient = bob; - return Promise.all([ - bob.createRoom({ name: "Room to Join" }), - bob.createRoom({ name: "Room to Reject" }), - bob.createRoom({ name: "Room to Rescind" }), - ]); - }) - .then(([join, reject, rescind]) => { - roomJoin = join.room_id; - roomReject = reject.room_id; - roomRescind = rescind.room_id; - return Promise.all([ - bobClient.invite(roomJoin, clientUserId), - bobClient.invite(roomReject, clientUserId), - bobClient.invite(roomRescind, clientUserId), - ]); - }); - - cy.findByRole("group", { name: "Invites" }).within(() => { - // Exclude headerText - cy.get(".mx_RoomSublist_tiles").within(() => { - // Wait for them all to be on the UI - cy.findAllByRole("treeitem").should("have.length", 3); - }); - }); - - // Select the room to join - cy.findByRole("treeitem", { name: "Room to Join" }).click(); - - cy.get(".mx_RoomView").within(() => { - // Accept the invite - cy.findByRole("button", { name: "Accept" }).click(); - }); - - checkOrder(["Room to Join", "Test Room"]); - - // Select the room to reject - cy.findByRole("treeitem", { name: "Room to Reject" }).click(); - - cy.get(".mx_RoomView").within(() => { - // Reject the invite - cy.findByRole("button", { name: "Reject" }).click(); - }); - - cy.findByRole("group", { name: "Invites" }).within(() => { - // Exclude headerText - cy.get(".mx_RoomSublist_tiles").within(() => { - // Wait for the rejected room to disappear - cy.findAllByRole("treeitem").should("have.length", 2); - }); - }); - - // check the lists are correct - checkOrder(["Room to Join", "Test Room"]); - - cy.findByRole("group", { name: "Invites" }) - .find(".mx_RoomTile_title") - .should((elements) => { - expect( - _.map(elements, (e) => { - return e.textContent; - }), - "rooms are sorted", - ).to.deep.equal(["Room to Rescind"]); - }); - - // now rescind the invite - cy.get("@bob").then((bob) => { - return bob.kick(roomRescind, clientUserId); - }); - - cy.findByRole("group", { name: "Rooms" }).within(() => { - // Exclude headerText - cy.get(".mx_RoomSublist_tiles").within(() => { - // Wait for the rescind to take effect and check the joined list once more - cy.findAllByRole("treeitem").should("have.length", 2); - }); - }); - - checkOrder(["Room to Join", "Test Room"]); - }); - - it("should show a favourite DM only in the favourite sublist", () => { - cy.createRoom({ - name: "Favourite DM", - is_direct: true, - }) - .as("room") - .then((roomId) => { - cy.getClient().then((cli) => cli.setRoomTag(roomId, "m.favourite", { order: 0.5 })); - }); - - cy.findByRole("group", { name: "Favourites" }).findByText("Favourite DM").should("exist"); - cy.findByRole("group", { name: "People" }).findByText("Favourite DM").should("not.exist"); - }); - - // Regression test for a bug in SS mode, but would be useful to have in non-SS mode too. - // This ensures we are setting RoomViewStore state correctly. - it.skip("should clear the reply to field when swapping rooms", () => { - cy.createRoom({ name: "Other Room" }) - .as("roomA") - .then(() => cy.findByRole("treeitem", { name: "Other Room" })); - cy.get("@roomId").then((roomId) => { - return cy.sendEvent(roomId, null, "m.room.message", { - body: "Hello world", - msgtype: "m.text", - }); - }); - // select the room - cy.findByRole("treeitem", { name: "Test Room" }).click(); - cy.get(".mx_ReplyPreview").should("not.exist"); - // click reply-to on the Hello World message - cy.get(".mx_EventTile_last") - .within(() => { - cy.findByText("Hello world", { timeout: 1000 }); - }) - .realHover() - .findByRole("button", { name: "Reply" }) - .click(); - // check it's visible - cy.get(".mx_ReplyPreview").should("exist"); - // now click Other Room - cy.findByRole("treeitem", { name: "Other Room" }).click(); - // ensure the reply-to disappears - cy.get(".mx_ReplyPreview").should("not.exist"); - // click back - cy.findByRole("treeitem", { name: "Test Room" }).click(); - // ensure the reply-to reappears - cy.get(".mx_ReplyPreview").should("exist"); - }); - - // Regression test for https://github.com/vector-im/element-web/issues/21462 - it.skip("should not cancel replies when permalinks are clicked", () => { - cy.get("@roomId").then((roomId) => { - // we require a first message as you cannot click the permalink text with the avatar in the way - return cy - .sendEvent(roomId, null, "m.room.message", { - body: "First message", - msgtype: "m.text", - }) - .then(() => { - return cy.sendEvent(roomId, null, "m.room.message", { - body: "Permalink me", - msgtype: "m.text", - }); - }) - .then(() => { - cy.sendEvent(roomId, null, "m.room.message", { - body: "Reply to me", - msgtype: "m.text", - }); - }); - }); - // select the room - cy.findByRole("treeitem", { name: "Test Room" }).click(); - cy.get(".mx_ReplyPreview").should("not.exist"); - // click reply-to on the Reply to me message - cy.get(".mx_EventTile") - .last() - .within(() => { - cy.findByText("Reply to me"); - }) - .realHover() - .findByRole("button", { name: "Reply" }) - .click(); - // check it's visible - cy.get(".mx_ReplyPreview").should("exist"); - // now click on the permalink for Permalink me - cy.contains(".mx_EventTile", "Permalink me").find("a").click({ force: true }); - // make sure it is now selected with the little green | - cy.contains(".mx_EventTile_selected", "Permalink me").should("exist"); - // ensure the reply-to does not disappear - cy.get(".mx_ReplyPreview").should("exist"); - }); - - it.skip("should send unsubscribe_rooms for every room switch", () => { - let roomAId: string; - let roomPId: string; - // create rooms and check room names are correct - cy.createRoom({ name: "Apple" }) - .as("roomA") - .then((roomId) => (roomAId = roomId)) - .then(() => cy.findByRole("treeitem", { name: "Apple" })); - - cy.createRoom({ name: "Pineapple" }) - .as("roomP") - .then((roomId) => (roomPId = roomId)) - .then(() => cy.findByRole("treeitem", { name: "Pineapple" })); - cy.createRoom({ name: "Orange" }) - .as("roomO") - .then(() => cy.findByRole("treeitem", { name: "Orange" })); - - // Intercept all calls to /sync - cy.intercept({ method: "POST", url: "**/sync*" }).as("syncRequest"); - - const assertUnsubExists = (interception: Interception, subRoomId: string, unsubRoomId: string) => { - const body = interception.request.body; - // There may be a request without a txn_id, ignore it, as there won't be any subscription changes - if (body.txn_id === undefined) { - return; - } - expect(body.unsubscribe_rooms).eql([unsubRoomId]); - expect(body.room_subscriptions).to.not.have.property(unsubRoomId); - expect(body.room_subscriptions).to.have.property(subRoomId); - }; - - // Select the Test Room - cy.findByRole("treeitem", { name: "Apple" }).click(); - - // and wait for cypress to get the result as alias - cy.wait("@syncRequest").then((interception) => { - // This is the first switch, so no unsubscriptions yet. - assert.isObject(interception.request.body.room_subscriptions, "room_subscriptions is object"); - }); - - // Switch to another room - cy.findByRole("treeitem", { name: "Pineapple" }).click(); - cy.wait("@syncRequest").then((interception) => assertUnsubExists(interception, roomPId, roomAId)); - - // And switch to even another room - cy.findByRole("treeitem", { name: "Apple" }).click(); - cy.wait("@syncRequest").then((interception) => assertUnsubExists(interception, roomPId, roomAId)); - - // TODO: Add tests for encrypted rooms - }); -}); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index a0bd7a5c7f..d13f025caa 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -22,7 +22,6 @@ import PluginEvents = Cypress.PluginEvents; import PluginConfigOptions = Cypress.PluginConfigOptions; import { synapseDocker } from "./synapsedocker"; import { dendriteDocker } from "./dendritedocker"; -import { slidingSyncProxyDocker } from "./sliding-sync"; import { webserver } from "./webserver"; import { docker } from "./docker"; import { log } from "./log"; @@ -31,7 +30,7 @@ import { log } from "./log"; * @type {Cypress.PluginConfig} */ export default function (on: PluginEvents, config: PluginConfigOptions) { - initPlugins(on, [docker, synapseDocker, dendriteDocker, slidingSyncProxyDocker, webserver, log], config); + initPlugins(on, [docker, synapseDocker, dendriteDocker, webserver, log], config); installLogsPrinter(on, { printLogsToConsole: "never", diff --git a/cypress/plugins/sliding-sync/index.ts b/cypress/plugins/sliding-sync/index.ts deleted file mode 100644 index ab39c7a42b..0000000000 --- a/cypress/plugins/sliding-sync/index.ts +++ /dev/null @@ -1,129 +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 PluginEvents = Cypress.PluginEvents; -import PluginConfigOptions = Cypress.PluginConfigOptions; -import { dockerExec, dockerIp, dockerRun, dockerStop } from "../docker"; -import { getFreePort } from "../utils/port"; -import { HomeserverInstance } from "../utils/homeserver"; - -// A cypress plugin to add command to start & stop https://github.com/matrix-org/sliding-sync -// SLIDING_SYNC_PROXY_TAG env used as the docker tag to use for `ghcr.io/matrix-org/sliding-sync` image. - -export interface ProxyInstance { - containerId: string; - postgresId: string; - port: number; -} - -const instances = new Map(); - -const PG_PASSWORD = "p4S5w0rD"; - -async function proxyStart(dockerTag: string, homeserver: HomeserverInstance): Promise { - console.log(new Date(), "Starting sliding sync proxy..."); - - const postgresId = await dockerRun({ - image: "postgres", - containerName: "react-sdk-cypress-sliding-sync-postgres", - params: ["--rm", "-e", `POSTGRES_PASSWORD=${PG_PASSWORD}`], - }); - - const postgresIp = await dockerIp({ containerId: postgresId }); - const homeserverIp = await dockerIp({ containerId: homeserver.serverId }); - console.log(new Date(), "postgres container up"); - - const waitTimeMillis = 30000; - const startTime = new Date().getTime(); - let lastErr: Error; - while (new Date().getTime() - startTime < waitTimeMillis) { - try { - await dockerExec({ - containerId: postgresId, - params: ["pg_isready", "-U", "postgres"], - }); - lastErr = null; - break; - } catch (err) { - console.log("pg_isready: failed"); - lastErr = err; - } - } - if (lastErr) { - console.log("rethrowing"); - throw lastErr; - } - - const port = await getFreePort(); - console.log(new Date(), "starting proxy container...", dockerTag); - const containerId = await dockerRun({ - image: "ghcr.io/matrix-org/sliding-sync:" + dockerTag, - containerName: "react-sdk-cypress-sliding-sync-proxy", - params: [ - "--rm", - "-p", - `${port}:8008/tcp`, - "-e", - "SYNCV3_SECRET=bwahahaha", - "-e", - `SYNCV3_SERVER=http://${homeserverIp}:8008`, - "-e", - `SYNCV3_DB=user=postgres dbname=postgres password=${PG_PASSWORD} host=${postgresIp} sslmode=disable`, - ], - }); - console.log(new Date(), "started!"); - - const instance: ProxyInstance = { containerId, postgresId, port }; - instances.set(containerId, instance); - return instance; -} - -async function proxyStop(instance: ProxyInstance): Promise { - await dockerStop({ - containerId: instance.containerId, - }); - await dockerStop({ - containerId: instance.postgresId, - }); - - instances.delete(instance.containerId); - - console.log(new Date(), "Stopped sliding sync proxy."); - // cypress deliberately fails if you return 'undefined', so - // return null to signal all is well, and we've handled the task. - return null; -} - -/** - * @type {Cypress.PluginConfig} - */ -export function slidingSyncProxyDocker(on: PluginEvents, config: PluginConfigOptions) { - const dockerTag = config.env["SLIDING_SYNC_PROXY_TAG"]; - - on("task", { - proxyStart: proxyStart.bind(null, dockerTag), - proxyStop, - }); - - on("after:spec", async (spec) => { - for (const instance of instances.values()) { - console.warn(`Cleaning up proxy on port ${instance.port} after ${spec.name}`); - await proxyStop(instance); - } - }); -} diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 5cec756741..cce1534cd9 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -38,7 +38,6 @@ import "./iframes"; import "./timeline"; import "./network"; import "./composer"; -import "./proxy"; import "./axe"; import "./promise"; diff --git a/cypress/support/proxy.ts b/cypress/support/proxy.ts deleted file mode 100644 index b40584ec7f..0000000000 --- a/cypress/support/proxy.ts +++ /dev/null @@ -1,58 +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; -import AUTWindow = Cypress.AUTWindow; -import { ProxyInstance } from "../plugins/sliding-sync"; -import { HomeserverInstance } from "../plugins/utils/homeserver"; - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Cypress { - interface Chainable { - /** - * Start a sliding sync proxy instance. - * @param homeserver the homeserver instance returned by startHomeserver - */ - startProxy(homeserver: HomeserverInstance): Chainable; - - /** - * Custom command wrapping task:proxyStop whilst preventing uncaught exceptions - * for if Docker stopping races with the app's background sync loop. - * @param proxy the proxy instance returned by startProxy - */ - stopProxy(proxy: ProxyInstance): Chainable; - } - } -} - -function startProxy(homeserver: HomeserverInstance): Chainable { - return cy.task("proxyStart", homeserver); -} - -function stopProxy(proxy?: ProxyInstance): Chainable { - if (!proxy) return; - // Navigate away from app to stop the background network requests which will race with Homeserver shutting down - return cy.window({ log: false }).then((win) => { - win.location.href = "about:blank"; - cy.task("proxyStop", proxy); - }); -} - -Cypress.Commands.add("startProxy", startProxy); -Cypress.Commands.add("stopProxy", stopProxy); diff --git a/playwright/e2e/right-panel/notification-panel.spec.ts b/playwright/e2e/right-panel/notification-panel.spec.ts index 9e3f7e03de..6223c1c13f 100644 --- a/playwright/e2e/right-panel/notification-panel.spec.ts +++ b/playwright/e2e/right-panel/notification-panel.spec.ts @@ -22,6 +22,7 @@ const NAME = "Alice"; test.describe("NotificationPanel", () => { test.use({ displayName: NAME, + labsFlags: ["feature_notifications"], }); test.beforeEach(async ({ app, user }) => { @@ -29,7 +30,6 @@ test.describe("NotificationPanel", () => { }); test("should render empty state", async ({ page, app }) => { - await app.labs.enableLabsFeature("feature_notifications"); await app.viewRoomByName(ROOM_NAME); await page.getByRole("button", { name: "Notifications" }).click(); diff --git a/playwright/e2e/room/room-header.spec.ts b/playwright/e2e/room/room-header.spec.ts index 45bb6a6810..ab9ed9ff7e 100644 --- a/playwright/e2e/room/room-header.spec.ts +++ b/playwright/e2e/room/room-header.spec.ts @@ -25,10 +25,9 @@ test.describe("Room Header", () => { }); test.describe("with feature_notifications enabled", () => { - test.beforeEach(async ({ app }) => { - await app.labs.enableLabsFeature("feature_notifications"); + 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"); @@ -101,9 +100,7 @@ test.describe("Room Header", () => { }); test.describe("with feature_pinning enabled", () => { - test.beforeEach(async ({ app }) => { - await app.labs.enableLabsFeature("feature_pinning"); - }); + test.use({ labsFlags: ["feature_pinning"] }); test("should render the pin button for pinned messages card", async ({ page, app, user }) => { await app.client.createRoom({ name: "Test Room" }); @@ -126,9 +123,7 @@ test.describe("Room Header", () => { }); test.describe("with a video room", () => { - test.beforeEach(async ({ app }) => { - await app.labs.enableLabsFeature("feature_video_rooms"); - }); + 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(); @@ -142,33 +137,36 @@ test.describe("Room Header", () => { await app.viewRoomByName("Test video room"); }; - test("should render buttons for room options, beta pill, invite, chat, and room info", async ({ - page, - app, - user, - }) => { - await app.labs.enableLabsFeature("feature_notifications"); - await createVideoRoom(page, app); + test.describe("and with feature_notifications enabled", () => { + test.use({ labsFlags: ["feature_video_rooms", "feature_notifications"] }); - const header = page.locator(".mx_LegacyRoomHeader"); - // Names (aria-label) of the buttons on the video room header - const expectedButtonNames = [ - "Room options", - "Video rooms are a beta feature Click for more info", // Beta pill - "Invite", - "Chat", - "Room info", - ]; + test("should render buttons for room options, beta pill, invite, chat, and room info", async ({ + page, + app, + user, + }) => { + await createVideoRoom(page, app); - // Assert they are found and visible - for (const name of expectedButtonNames) { - await expect(header.getByRole("button", { name })).toBeVisible(); - } + const header = page.locator(".mx_LegacyRoomHeader"); + // Names (aria-label) of the buttons on the video room header + const expectedButtonNames = [ + "Room options", + "Video rooms are a beta feature Click for more info", // Beta pill + "Invite", + "Chat", + "Room info", + ]; - // Assert that there is not a button except those buttons - await expect(header.getByRole("button")).toHaveCount(7); + // Assert they are found and visible + for (const name of expectedButtonNames) { + await expect(header.getByRole("button", { name })).toBeVisible(); + } - await expect(header).toMatchScreenshot("room-header-video-room.png"); + // 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 ({ diff --git a/playwright/e2e/sliding-sync/sliding-sync.spec.ts b/playwright/e2e/sliding-sync/sliding-sync.spec.ts new file mode 100644 index 0000000000..1a9fa08d29 --- /dev/null +++ b/playwright/e2e/sliding-sync/sliding-sync.spec.ts @@ -0,0 +1,375 @@ +/* +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 { Page, Request } from "@playwright/test"; + +import { test, expect } from "../../element-web-test"; +import type { ElementAppPage } from "../../pages/ElementAppPage"; +import type { Bot } from "../../pages/bot"; + +test.describe("Sliding Sync", () => { + let roomId: string; + + test.beforeEach(async ({ slidingSyncProxy, page, user, app }) => { + roomId = await app.client.createRoom({ name: "Test Room" }); + }); + + const checkOrder = async (wantOrder: string[], page: Page) => { + await expect(page.getByRole("group", { name: "Rooms" }).locator(".mx_RoomTile_title")).toHaveText(wantOrder); + }; + + const bumpRoom = async (roomId: string, app: ElementAppPage) => { + // Send a message into the given room, this should bump the room to the top + console.log("sendEvent", app.client.sendEvent); + await app.client.sendEvent(roomId, null, "m.room.message", { + body: "Hello world", + msgtype: "m.text", + }); + }; + + const createAndJoinBot = async (app: ElementAppPage, bot: Bot): Promise => { + await bot.prepareClient(); + const bobUserId = await bot.evaluate((client) => client.getUserId()); + await app.client.evaluate( + async (client, { bobUserId, roomId }) => { + await client.invite(roomId, bobUserId); + }, + { bobUserId, roomId }, + ); + await bot.joinRoom(roomId); + return bot; + }; + + test.skip("should render the Rooms list in reverse chronological order by default and allowing sorting A-Z", async ({ + page, + app, + }) => { + // create rooms and check room names are correct + for (const fruit of ["Apple", "Pineapple", "Orange"]) { + await app.client.createRoom({ name: fruit }); + await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible(); + } + + // Check count, 3 fruits + 1 room created in beforeEach = 4 + await expect(page.locator(".mx_RoomSublist_tiles").getByRole("treeitem")).toHaveCount(4); + await checkOrder(["Orange", "Pineapple", "Apple", "Test Room"], page); + + const locator = page.getByRole("group", { name: "Rooms" }).locator(".mx_RoomSublist_headerContainer"); + await locator.hover(); + await locator.getByRole("button", { name: "List options" }).click(); + + // force click as the radio button's size is zero + await page.getByRole("menuitemradio", { name: "A-Z" }).dispatchEvent("click"); + await expect(page.locator(".mx_StyledRadioButton_checked").getByText("A-Z")).toBeVisible(); + + await page.pause(); + await checkOrder(["Apple", "Orange", "Pineapple", "Test Room"], page); + }); + + test.skip("should move rooms around as new events arrive", async ({ page, app }) => { + // create rooms and check room names are correct + const roomIds: string[] = []; + for (const fruit of ["Apple", "Pineapple", "Orange"]) { + const id = await app.client.createRoom({ name: fruit }); + roomIds.push(id); + await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible(); + } + + // Select the Test Room + await page.getByRole("treeitem", { name: "Test Room" }).click(); + const [apple, pineapple, orange] = roomIds; + await checkOrder(["Orange", "Pineapple", "Apple", "Test Room"], page); + await bumpRoom(apple, app); + await checkOrder(["Apple", "Orange", "Pineapple", "Test Room"], page); + await bumpRoom(orange, app); + await checkOrder(["Orange", "Apple", "Pineapple", "Test Room"], page); + await bumpRoom(orange, app); + await checkOrder(["Orange", "Apple", "Pineapple", "Test Room"], page); + await bumpRoom(pineapple, app); + await checkOrder(["Pineapple", "Orange", "Apple", "Test Room"], page); + }); + + test.skip("should not move the selected room: it should be sticky", async ({ page, app }) => { + // create rooms and check room names are correct + const roomIds: string[] = []; + for (const fruit of ["Apple", "Pineapple", "Orange"]) { + const id = await app.client.createRoom({ name: fruit }); + roomIds.push(id); + await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible(); + } + + // Given a list of Orange, Pineapple, Apple - if Pineapple is active and a message is sent in Apple, the list should + // turn into Apple, Pineapple, Orange - the index position of Pineapple never changes even though the list should technically + // be Apple, Orange Pineapple - only when you click on a different room do things reshuffle. + + // Select the Pineapple room + await page.getByRole("treeitem", { name: "Pineapple" }).click(); + await checkOrder(["Orange", "Pineapple", "Apple", "Test Room"], page); + + // Move Apple + await bumpRoom(roomIds[0], app); + await checkOrder(["Apple", "Pineapple", "Orange", "Test Room"], page); + + // Select the Test Room + await page.getByRole("treeitem", { name: "Test Room" }).click(); + + // the rooms reshuffle to match reality + await checkOrder(["Apple", "Orange", "Pineapple", "Test Room"], page); + }); + + test.skip("should show the right unread notifications", async ({ page, app, user, bot }) => { + const bob = await createAndJoinBot(app, bot); + + // send a message in the test room: unread notification count should increment + await bob.sendTextMessage(roomId, "Hello World"); + + const treeItemLocator1 = page.getByRole("treeitem", { name: "Test Room 1 unread message." }); + await expect(treeItemLocator1.locator(".mx_NotificationBadge_count")).toHaveText("1"); + // await expect(page.locator(".mx_NotificationBadge")).not.toHaveClass("mx_NotificationBadge_highlighted"); + await expect(treeItemLocator1.locator(".mx_NotificationBadge")).not.toHaveClass( + /mx_NotificationBadge_highlighted/, + ); + + // send an @mention: highlight count (red) should be 2. + await bob.sendTextMessage(roomId, `Hello ${user.displayName}`); + const treeItemLocator2 = page.getByRole("treeitem", { + name: "Test Room 2 unread messages including mentions.", + }); + await expect(treeItemLocator2.locator(".mx_NotificationBadge_count")).toHaveText("2"); + await expect(treeItemLocator2.locator(".mx_NotificationBadge")).toHaveClass(/mx_NotificationBadge_highlighted/); + + // click on the room, the notif counts should disappear + await page.getByRole("treeitem", { name: "Test Room 2 unread messages including mentions." }).click(); + await expect( + page.getByRole("treeitem", { name: "Test Room" }).locator("mx_NotificationBadge_count"), + ).not.toBeAttached(); + }); + + test.skip("should not show unread indicators", async ({ page, app, bot }) => { + // TODO: for now. Later we should. + await createAndJoinBot(app, bot); + + // disable notifs in this room (TODO: CS API call?) + const locator = page.getByRole("treeitem", { name: "Test Room" }); + await locator.hover(); + await locator.getByRole("button", { name: "Notification options" }).click(); + await page.getByRole("menuitemradio", { name: "Mute room" }).click(); + + // create a new room so we know when the message has been received as it'll re-shuffle the room list + await app.client.createRoom({ name: "Dummy" }); + + await checkOrder(["Dummy", "Test Room"], page); + + await bot.sendTextMessage(roomId, "Do you read me?"); + + // wait for this message to arrive, tell by the room list resorting + await checkOrder(["Test Room", "Dummy"], page); + + await expect( + page.getByRole("treeitem", { name: "Test Room" }).locator(".mx_NotificationBadge"), + ).not.toBeAttached(); + }); + + test("should update user settings promptly", async ({ page, app }) => { + await app.settings.openUserSettings("Preferences"); + const locator = page.locator(".mx_SettingsFlag").filter({ hasText: "Show timestamps in 12 hour format" }); + expect(locator).toBeVisible(); + expect(locator.locator(".mx_ToggleSwitch_on")).not.toBeAttached(); + await locator.locator(".mx_ToggleSwitch_ball").click(); + expect(locator.locator(".mx_ToggleSwitch_on")).toBeAttached(); + }); + + test.skip("should show and be able to accept/reject/rescind invites", async ({ page, app, bot }) => { + await createAndJoinBot(app, bot); + + const clientUserId = await app.client.evaluate((client) => client.getUserId()); + + // invite bot into 3 rooms: + // - roomJoin: will join this room + // - roomReject: will reject the invite + // - roomRescind: will make Bob rescind the invite + const roomNames = ["Room to Join", "Room to Reject", "Room to Rescind"]; + const roomRescind = await bot.evaluate( + async (client, { roomNames, clientUserId }) => { + const rooms = await Promise.all(roomNames.map((name) => client.createRoom({ name }))); + await Promise.all(rooms.map((room) => client.invite(room.room_id, clientUserId))); + return rooms[2].room_id; + }, + { roomNames, clientUserId }, + ); + + await expect( + page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), + ).toHaveCount(3); + + // Select the room to join + await page.getByRole("treeitem", { name: "Room to Join" }).click(); + + // Accept the invite + await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); + + await checkOrder(["Room to Join", "Test Room"], page); + + // Select the room to reject + await page.getByRole("treeitem", { name: "Room to Reject" }).click(); + + // Reject the invite + await page.locator(".mx_RoomView").getByRole("button", { name: "Reject", exact: true }).click(); + + await expect( + page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), + ).toHaveCount(2); + + // check the lists are correct + await checkOrder(["Room to Join", "Test Room"], page); + + const titleLocator = page.getByRole("group", { name: "Invites" }).locator(".mx_RoomTile_title"); + await expect(titleLocator).toHaveCount(1); + await expect(titleLocator).toHaveText("Room to Rescind"); + + // now rescind the invite + await bot.evaluate( + async (client, { roomRescind, clientUserId }) => { + client.kick(roomRescind, clientUserId); + }, + { roomRescind, clientUserId }, + ); + + // Wait for the rescind to take effect and check the joined list once more + await expect( + page.getByRole("group", { name: "Rooms" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), + ).toHaveCount(2); + + await checkOrder(["Room to Join", "Test Room"], page); + }); + + test("should show a favourite DM only in the favourite sublist", async ({ page, app }) => { + const roomId = await app.client.createRoom({ + name: "Favourite DM", + is_direct: true, + }); + await app.client.evaluate(async (client, roomId) => { + client.setRoomTag(roomId, "m.favourite", { order: 0.5 }); + }, roomId); + await expect(page.getByRole("group", { name: "Favourites" }).getByText("Favourite DM")).toBeVisible(); + await expect(page.getByRole("group", { name: "People" }).getByText("Favourite DM")).not.toBeAttached(); + }); + + // Regression test for a bug in SS mode, but would be useful to have in non-SS mode too. + // This ensures we are setting RoomViewStore state correctly. + 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"); + + // select the room + await page.getByRole("treeitem", { name: "Test Room" }).click(); + + await expect(page.locator(".mx_ReplyPreview")).not.toBeAttached(); + + // click reply-to on the Hello World message + const locator = page.locator(".mx_EventTile_last"); + await locator.getByText("Hello world").hover(); + await locator.getByRole("button", { name: "Reply", exact: true }).click({}); + + // check it's visible + await expect(page.locator(".mx_ReplyPreview")).toBeVisible(); + + // now click Other Room + await page.getByRole("treeitem", { name: "Other Room" }).click(); + + // ensure the reply-to disappears + await expect(page.locator(".mx_ReplyPreview")).not.toBeAttached(); + + // click back + await page.getByRole("treeitem", { name: "Test Room" }).click(); + + // ensure the reply-to reappears + await expect(page.locator(".mx_ReplyPreview")).toBeVisible(); + }); + + // 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"); + + // select the room + await page.getByRole("treeitem", { name: "Test Room" }).click(); + await expect(page.locator(".mx_ReplyPreview")).not.toBeAttached(); + + // click reply-to on the Reply to me message + const locator = page.locator(".mx_EventTile").last(); + await locator.getByText("Reply to me").hover(); + await locator.getByRole("button", { name: "Reply", exact: true }).click(); + + // check it's visible + await expect(page.locator(".mx_ReplyPreview")).toBeVisible(); + + // now click on the permalink for Permalink me + await page.locator(".mx_EventTile").filter({ hasText: "Permalink me" }).locator("a").dispatchEvent("click"); + + // make sure it is now selected with the little green | + await expect(page.locator(".mx_EventTile_selected").filter({ hasText: "Permalink me" })).toBeVisible(); + + // ensure the reply-to does not disappear + await expect(page.locator(".mx_ReplyPreview")).toBeVisible(); + }); + + test.skip("should send unsubscribe_rooms for every room switch", async ({ page, app }) => { + // create rooms and check room names are correct + const roomIds: string[] = []; + for (const fruit of ["Apple", "Pineapple", "Orange"]) { + const id = await app.client.createRoom({ name: fruit }); + roomIds.push(id); + await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible(); + } + const [roomAId, roomPId] = roomIds; + + const assertUnsubExists = (request: Request, subRoomId: string, unsubRoomId: string) => { + const body = request.postDataJSON(); + // There may be a request without a txn_id, ignore it, as there won't be any subscription changes + if (body.txn_id === undefined) { + return; + } + expect(body.unsubscribe_rooms).toEqual([unsubRoomId]); + expect(body.room_subscriptions).not.toHaveProperty(unsubRoomId); + expect(body.room_subscriptions).toHaveProperty(subRoomId); + }; + + let promise = page.waitForRequest(/sync/); + + // Select the Test Room + await page.getByRole("treeitem", { name: "Apple", exact: true }).click(); + + // and wait for playwright to get the request + const roomSubscriptions = (await promise).postDataJSON().room_subscriptions; + expect(roomSubscriptions, "room_subscriptions is object").toBeDefined(); + + // Switch to another room + promise = page.waitForRequest(/sync/); + await page.getByRole("treeitem", { name: "Pineapple", exact: true }).click(); + assertUnsubExists(await promise, roomPId, roomAId); + + // And switch to even another room + promise = page.waitForRequest(/sync/); + await page.getByRole("treeitem", { name: "Apple", exact: true }).click(); + assertUnsubExists(await promise, roomPId, roomAId); + + // TODO: Add tests for encrypted rooms + }); +}); diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index d34406b54d..5a28518878 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -30,6 +30,7 @@ import { OAuthServer } from "./plugins/oauth_server"; import { Crypto } from "./pages/crypto"; import { Toasts } from "./pages/toasts"; import { Bot, CreateBotOpts } from "./pages/bot"; +import { ProxyInstance, SlidingSyncProxy } from "./plugins/sliding-sync-proxy"; import { Webserver } from "./plugins/webserver"; const CONFIG_JSON: Partial = { @@ -82,6 +83,7 @@ export const test = base.extend< uut?: Locator; // Unit Under Test, useful place to refer a prepared locator botCreateOpts: CreateBotOpts; bot: Bot; + slidingSyncProxy: ProxyInstance; labsFlags: string[]; webserver: Webserver; } @@ -104,7 +106,6 @@ export const test = base.extend< } await route.fulfill({ json }); }); - await use(page); }, @@ -180,7 +181,6 @@ export const test = base.extend< { baseUrl: homeserver.config.baseUrl, credentials }, ); await page.goto("/"); - await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 }); await use(credentials); @@ -219,6 +219,25 @@ export const test = base.extend< await use(bot); }, + slidingSyncProxy: async ({ page, user, homeserver }, use) => { + const proxy = new SlidingSyncProxy(homeserver.config.dockerUrl); + const proxyInstance = await proxy.start(); + const proxyAddress = `http://localhost:${proxyInstance.port}`; + await page.addInitScript((proxyAddress) => { + window.localStorage.setItem( + "mx_local_settings", + JSON.stringify({ + feature_sliding_sync_proxy_url: proxyAddress, + }), + ); + window.localStorage.setItem("mx_labs_feature_feature_sliding_sync", "true"); + }, proxyAddress); + await page.goto("/"); + await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 }); + await use(proxyInstance); + await proxy.stop(); + }, + // eslint-disable-next-line no-empty-pattern webserver: async ({}, use) => { const webserver = new Webserver(); diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts index 8bc0f5ae0e..1efc98d497 100644 --- a/playwright/pages/ElementAppPage.ts +++ b/playwright/pages/ElementAppPage.ts @@ -18,13 +18,11 @@ import { type Locator, type Page, expect } from "@playwright/test"; import { Settings } from "./settings"; import { Client } from "./client"; -import { Labs } from "./labs"; import { Spotlight } from "./Spotlight"; export class ElementAppPage { public constructor(public readonly page: Page) {} - public labs = new Labs(this.page); public settings = new Settings(this.page); public client: Client = new Client(this.page); diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts index a197f09333..7f9180ca2e 100644 --- a/playwright/pages/client.ts +++ b/playwright/pages/client.ts @@ -101,7 +101,7 @@ export class Client { } /** - * Send a message as a bot into a room + * Send a message into a room * @param roomId ID of the room to send the message into * @param content the event content to send */ @@ -134,6 +134,15 @@ 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 diff --git a/playwright/pages/labs.ts b/playwright/pages/labs.ts deleted file mode 100644 index 55bce18225..0000000000 --- a/playwright/pages/labs.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { Page } from "playwright-core"; - -export class Labs { - constructor(private page: Page) {} - - /** - * Enables a labs feature for an element session. - * @param feature labsFeature to enable (e.g. "feature_spotlight") - */ - public async enableLabsFeature(feature: string): Promise { - if (this.page.url() === "about:blank") { - await this.page.addInitScript((feature) => { - window.localStorage.setItem(`mx_labs_feature_${feature}`, "true"); - }, feature); - } else { - await this.page.evaluate((feature) => { - window.localStorage.setItem(`mx_labs_feature_${feature}`, "true"); - }, feature); - } - } -} diff --git a/playwright/plugins/homeserver/dendrite/index.ts b/playwright/plugins/homeserver/dendrite/index.ts index a9823f5b05..2ca54cc0d8 100644 --- a/playwright/plugins/homeserver/dendrite/index.ts +++ b/playwright/plugins/homeserver/dendrite/index.ts @@ -74,9 +74,11 @@ export class Dendrite extends Synapse implements Homeserver, HomeserverInstance "http://localhost:8008/_matrix/client/versions", ]); + const dockerUrl = `http://${await this.docker.getContainerIp()}:8008`; this.config = { ...denCfg, serverId: dendriteId, + dockerUrl, }; return this; } @@ -107,7 +109,10 @@ export class Pinecone extends Dendrite { protected entrypoint = "/usr/bin/dendrite-demo-pinecone"; } -async function cfgDirFromTemplate(dendriteImage: string, opts: StartHomeserverOpts): Promise { +async function cfgDirFromTemplate( + dendriteImage: string, + opts: StartHomeserverOpts, +): Promise> { const template = "default"; // XXX: for now we only have one template const templateDir = path.join(__dirname, "templates", template); diff --git a/playwright/plugins/homeserver/index.ts b/playwright/plugins/homeserver/index.ts index a4b9cdf98b..1e0cfb3b39 100644 --- a/playwright/plugins/homeserver/index.ts +++ b/playwright/plugins/homeserver/index.ts @@ -19,6 +19,7 @@ export interface HomeserverConfig { readonly baseUrl: string; readonly port: number; readonly registrationSecret: string; + readonly dockerUrl: string; } export interface HomeserverInstance { diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index ee789281ef..95165c1442 100644 --- a/playwright/plugins/homeserver/synapse/index.ts +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -25,7 +25,7 @@ import { Docker } from "../../docker"; import { HomeserverConfig, HomeserverInstance, Homeserver, StartHomeserverOpts, Credentials } from ".."; import { randB64Bytes } from "../../utils/rand"; -async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise { +async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); const stats = await fse.stat(templateDir); @@ -146,10 +146,11 @@ export class Synapse implements Homeserver, HomeserverInstance { "--silent", "http://localhost:8008/health", ]); - + const dockerUrl = `http://${await this.docker.getContainerIp()}:8008`; this.config = { ...synCfg, serverId: synapseId, + dockerUrl, }; return this; } diff --git a/playwright/plugins/sliding-sync-proxy/index.ts b/playwright/plugins/sliding-sync-proxy/index.ts new file mode 100644 index 0000000000..649f0092d6 --- /dev/null +++ b/playwright/plugins/sliding-sync-proxy/index.ts @@ -0,0 +1,99 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { getFreePort } from "../utils/port"; +import { Docker } from "../docker"; + +// Docker tag to use for `ghcr.io/matrix-org/sliding-sync` image. +const SLIDING_SYNC_PROXY_TAG = "v0.99.3"; +const PG_PASSWORD = "p4S5w0rD"; + +export interface ProxyInstance { + containerId: string; + postgresId: string; + port: number; +} + +export class SlidingSyncProxy { + private readonly postgresDocker = new Docker(); + private readonly proxyDocker = new Docker(); + private instance: ProxyInstance; + + constructor(private synapseIp: string) {} + + private async waitForPostgresReady(): Promise { + const waitTimeMillis = 30000; + const startTime = new Date().getTime(); + let lastErr: Error | null = null; + while (new Date().getTime() - startTime < waitTimeMillis) { + try { + await this.postgresDocker.exec(["pg_isready", "-U", "postgres"]); + lastErr = null; + break; + } catch (err) { + console.log("pg_isready: failed"); + lastErr = err; + } + } + if (lastErr) { + console.log("rethrowing"); + throw lastErr; + } + } + + async start(): Promise { + console.log(new Date(), "Starting sliding sync proxy..."); + + const postgresId = await this.postgresDocker.run({ + image: "postgres", + containerName: "react-sdk-cypress-sliding-sync-postgres", + params: ["--rm", "-e", `POSTGRES_PASSWORD=${PG_PASSWORD}`], + }); + + const postgresIp = await this.postgresDocker.getContainerIp(); + console.log(new Date(), "postgres container up"); + + await this.waitForPostgresReady(); + + const port = await getFreePort(); + console.log(new Date(), "starting proxy container...", SLIDING_SYNC_PROXY_TAG); + const containerId = await this.proxyDocker.run({ + image: "ghcr.io/matrix-org/sliding-sync:" + SLIDING_SYNC_PROXY_TAG, + containerName: "react-sdk-cypress-sliding-sync-proxy", + params: [ + "--rm", + "-p", + `${port}:8008/tcp`, + "-e", + "SYNCV3_SECRET=bwahahaha", + "-e", + `SYNCV3_SERVER=${this.synapseIp}`, + "-e", + `SYNCV3_DB=user=postgres dbname=postgres password=${PG_PASSWORD} host=${postgresIp} sslmode=disable`, + ], + }); + console.log(new Date(), "started!"); + + this.instance = { containerId, postgresId, port }; + return this.instance; + } + + async stop(): Promise { + await this.postgresDocker.stop(); + await this.proxyDocker.stop(); + console.log(new Date(), "Stopped sliding sync proxy."); + } +}