From c9008152c56e0dd441dbc31207267bd0aff07e0f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 12 Dec 2023 17:26:08 +0000 Subject: [PATCH] Migrate widgets/* from Cypress to Playwright (#12032) * Migrate send_event.spec.ts from Cypress to Playwright Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Migrate read_events.spec.ts from Cypress to Playwright Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Migrate kick.spec.ts from Cypress to Playwright Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Migrate get-openid-token.spec.ts from Cypress to Playwright Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Migrate layout.spec.ts from Cypress to Playwright Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Migrate events.spec.ts from Cypress to Playwright Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Migrate stickers.spec.ts from Cypress to Playwright Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Migrate widget-pip-close.spec.ts from Cypress to Playwright Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix types Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Add screenshot Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * expect.poll to stabilise test Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../get-openid-token.spec.ts | 141 --------- cypress/e2e/integration-manager/kick.spec.ts | 265 ----------------- .../integration-manager/read_events.spec.ts | 276 ------------------ .../integration-manager/send_event.spec.ts | 261 ----------------- cypress/e2e/widgets/events.spec.ts | 200 ------------- cypress/e2e/widgets/layout.spec.ts | 132 --------- cypress/e2e/widgets/widget-pip-close.spec.ts | 207 ------------- .../get-openid-token.spec.ts | 128 ++++++++ .../e2e/integration-manager/kick.spec.ts | 226 ++++++++++++++ .../integration-manager/read_events.spec.ts | 233 +++++++++++++++ .../integration-manager/send_event.spec.ts | 255 ++++++++++++++++ playwright/e2e/integration-manager/utils.ts | 25 ++ playwright/e2e/widgets/events.spec.ts | 176 +++++++++++ playwright/e2e/widgets/layout.spec.ts | 119 ++++++++ .../e2e/widgets/stickers.spec.ts | 136 ++++----- .../e2e/widgets/widget-pip-close.spec.ts | 169 +++++++++++ playwright/global.d.ts | 3 + playwright/pages/client.ts | 48 +++ playwright/plugins/webserver/index.ts | 2 +- .../layout.spec.ts/apps-drawer-linux.png | Bin 0 -> 6617 bytes 20 files changed, 1439 insertions(+), 1563 deletions(-) delete mode 100644 cypress/e2e/integration-manager/get-openid-token.spec.ts delete mode 100644 cypress/e2e/integration-manager/kick.spec.ts delete mode 100644 cypress/e2e/integration-manager/read_events.spec.ts delete mode 100644 cypress/e2e/integration-manager/send_event.spec.ts delete mode 100644 cypress/e2e/widgets/events.spec.ts delete mode 100644 cypress/e2e/widgets/layout.spec.ts delete mode 100644 cypress/e2e/widgets/widget-pip-close.spec.ts create mode 100644 playwright/e2e/integration-manager/get-openid-token.spec.ts create mode 100644 playwright/e2e/integration-manager/kick.spec.ts create mode 100644 playwright/e2e/integration-manager/read_events.spec.ts create mode 100644 playwright/e2e/integration-manager/send_event.spec.ts create mode 100644 playwright/e2e/integration-manager/utils.ts create mode 100644 playwright/e2e/widgets/events.spec.ts create mode 100644 playwright/e2e/widgets/layout.spec.ts rename {cypress => playwright}/e2e/widgets/stickers.spec.ts (52%) create mode 100644 playwright/e2e/widgets/widget-pip-close.spec.ts create mode 100644 playwright/snapshots/widgets/layout.spec.ts/apps-drawer-linux.png diff --git a/cypress/e2e/integration-manager/get-openid-token.spec.ts b/cypress/e2e/integration-manager/get-openid-token.spec.ts deleted file mode 100644 index b2dcb9146a..0000000000 --- a/cypress/e2e/integration-manager/get-openid-token.spec.ts +++ /dev/null @@ -1,141 +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 { UserCredentials } from "../../support/login"; - -const ROOM_NAME = "Integration Manager Test"; -const USER_DISPLAY_NAME = "Alice"; - -const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; -const INTEGRATION_MANAGER_HTML = ` - - - Fake Integration Manager - - - - -

No response

- - - -`; - -function openIntegrationManager() { - cy.findByRole("button", { name: "Room info" }).click(); - cy.findByRole("button", { name: "Add widgets, bridges & bots" }).click(); -} - -function sendActionFromIntegrationManager(integrationManagerUrl: string) { - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.findByRole("button", { name: "Press to send action" }).should("exist").click(); - }); -} - -describe("Integration Manager: Get OpenID Token", () => { - let testUser: UserCredentials; - let homeserver: HomeserverInstance; - let integrationManagerUrl: string; - - beforeEach(() => { - cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => { - integrationManagerUrl = url; - }); - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, USER_DISPLAY_NAME, () => { - cy.window().then((win) => { - win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN); - win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN); - }); - }).then((user) => { - testUser = user; - }); - - cy.setAccountData("m.widgets", { - "m.integration_manager": { - content: { - type: "m.integration_manager", - name: "Integration Manager", - url: integrationManagerUrl, - data: { - api_url: integrationManagerUrl, - }, - }, - id: "integration-manager", - }, - }).as("integrationManager"); - - // Succeed when checking the token is valid - cy.intercept(`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, (req) => { - req.continue((res) => { - return res.send(200, { - user_id: testUser.userId, - }); - }); - }); - - cy.createRoom({ - name: ROOM_NAME, - }).as("roomId"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.stopWebServers(); - }); - - it("should successfully obtain an openID token", () => { - cy.all([cy.get<{}>("@integrationManager")]).then(() => { - cy.viewRoomByName(ROOM_NAME); - - openIntegrationManager(); - sendActionFromIntegrationManager(integrationManagerUrl); - - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response").within(() => { - cy.findByText(/access_token/); - }); - }); - }); - }); -}); diff --git a/cypress/e2e/integration-manager/kick.spec.ts b/cypress/e2e/integration-manager/kick.spec.ts deleted file mode 100644 index 7075c1c199..0000000000 --- a/cypress/e2e/integration-manager/kick.spec.ts +++ /dev/null @@ -1,265 +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 { UserCredentials } from "../../support/login"; - -const ROOM_NAME = "Integration Manager Test"; -const USER_DISPLAY_NAME = "Alice"; -const BOT_DISPLAY_NAME = "Bob"; -const KICK_REASON = "Goodbye"; - -const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; -const INTEGRATION_MANAGER_HTML = ` - - - Fake Integration Manager - - - - - - - - - -`; - -function openIntegrationManager() { - cy.findByRole("button", { name: "Room info" }).click(); - cy.findByRole("button", { name: "Add widgets, bridges & bots" }).click(); -} - -function closeIntegrationManager(integrationManagerUrl: string) { - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.findByRole("button", { name: "Press to close" }).should("exist").click(); - }); -} - -function sendActionFromIntegrationManager(integrationManagerUrl: string, targetRoomId: string, targetUserId: string) { - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#target-room-id").should("exist").type(targetRoomId); - cy.get("#target-user-id").should("exist").type(targetUserId); - cy.findByRole("button", { name: "Press to send action" }).should("exist").click(); - }); -} - -function clickUntilGone(selector: string, attempt = 0) { - if (attempt === 11) { - throw new Error("clickUntilGone attempt count exceeded"); - } - - cy.get(selector) - .last() - .click() - .then(($button) => { - const exists = Cypress.$(selector).length > 0; - if (exists) { - clickUntilGone(selector, ++attempt); - } - }); -} - -function expectKickedMessage(shouldExist: boolean) { - // Expand any event summaries, we can't use a click multiple here because clicking one might de-render others - // This is quite horrible but seems the most stable way of clicking 0-N buttons, - // one at a time with a full re-evaluation after each click - clickUntilGone(".mx_GenericEventListSummary_toggle[aria-expanded=false]"); - - // Check for the event message (or lack thereof) - cy.findByText(`${USER_DISPLAY_NAME} removed ${BOT_DISPLAY_NAME}: ${KICK_REASON}`).should( - shouldExist ? "exist" : "not.exist", - ); -} - -describe("Integration Manager: Kick", () => { - let testUser: UserCredentials; - let homeserver: HomeserverInstance; - let integrationManagerUrl: string; - - beforeEach(() => { - cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => { - integrationManagerUrl = url; - }); - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, USER_DISPLAY_NAME, () => { - cy.window().then((win) => { - win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN); - win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN); - }); - }).then((user) => { - testUser = user; - }); - - cy.setAccountData("m.widgets", { - "m.integration_manager": { - content: { - type: "m.integration_manager", - name: "Integration Manager", - url: integrationManagerUrl, - data: { - api_url: integrationManagerUrl, - }, - }, - id: "integration-manager", - }, - }).as("integrationManager"); - - // Succeed when checking the token is valid - cy.intercept(`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, (req) => { - req.continue((res) => { - return res.send(200, { - user_id: testUser.userId, - }); - }); - }); - - cy.createRoom({ - name: ROOM_NAME, - }).as("roomId"); - - cy.getBot(homeserver, { displayName: BOT_DISPLAY_NAME, autoAcceptInvites: true }).as("bob"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.stopWebServers(); - }); - - it("should kick the target", () => { - cy.all([cy.get("@bob"), cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then( - ([targetUser, roomId]) => { - const targetUserId = targetUser.getUserId(); - cy.viewRoomByName(ROOM_NAME); - cy.inviteUser(roomId, targetUserId); - cy.findByText(`${BOT_DISPLAY_NAME} joined the room`).should("exist"); - - openIntegrationManager(); - sendActionFromIntegrationManager(integrationManagerUrl, roomId, targetUserId); - closeIntegrationManager(integrationManagerUrl); - expectKickedMessage(true); - }, - ); - }); - - it("should not kick the target if lacking permissions", () => { - cy.all([cy.get("@bob"), cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then( - ([targetUser, roomId]) => { - const targetUserId = targetUser.getUserId(); - cy.viewRoomByName(ROOM_NAME); - cy.inviteUser(roomId, targetUserId); - cy.findByText(`${BOT_DISPLAY_NAME} joined the room`).should("exist"); - cy.getClient() - .then(async (client) => { - await client.sendStateEvent(roomId, "m.room.power_levels", { - kick: 50, - users: { - [testUser.userId]: 0, - }, - }); - }) - .then(() => { - openIntegrationManager(); - sendActionFromIntegrationManager(integrationManagerUrl, roomId, targetUserId); - closeIntegrationManager(integrationManagerUrl); - expectKickedMessage(false); - }); - }, - ); - }); - - it("should no-op if the target already left", () => { - cy.all([cy.get("@bob"), cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then( - ([targetUser, roomId]) => { - const targetUserId = targetUser.getUserId(); - cy.viewRoomByName(ROOM_NAME); - cy.inviteUser(roomId, targetUserId); - cy.findByText(`${BOT_DISPLAY_NAME} joined the room`) - .should("exist") - .then(async () => { - await targetUser.leave(roomId); - }) - .then(() => { - openIntegrationManager(); - sendActionFromIntegrationManager(integrationManagerUrl, roomId, targetUserId); - closeIntegrationManager(integrationManagerUrl); - expectKickedMessage(false); - }); - }, - ); - }); - - it("should no-op if the target was banned", () => { - cy.all([cy.get("@bob"), cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then( - ([targetUser, roomId]) => { - const targetUserId = targetUser.getUserId(); - cy.viewRoomByName(ROOM_NAME); - cy.inviteUser(roomId, targetUserId); - cy.findByText(`${BOT_DISPLAY_NAME} joined the room`).should("exist"); - cy.getClient() - .then(async (client) => { - await client.ban(roomId, targetUserId); - }) - .then(() => { - openIntegrationManager(); - sendActionFromIntegrationManager(integrationManagerUrl, roomId, targetUserId); - closeIntegrationManager(integrationManagerUrl); - expectKickedMessage(false); - }); - }, - ); - }); - - it("should no-op if the target was never a room member", () => { - cy.all([cy.get("@bob"), cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then( - ([targetUser, roomId]) => { - const targetUserId = targetUser.getUserId(); - cy.viewRoomByName(ROOM_NAME); - - openIntegrationManager(); - sendActionFromIntegrationManager(integrationManagerUrl, roomId, targetUserId); - closeIntegrationManager(integrationManagerUrl); - expectKickedMessage(false); - }, - ); - }); -}); diff --git a/cypress/e2e/integration-manager/read_events.spec.ts b/cypress/e2e/integration-manager/read_events.spec.ts deleted file mode 100644 index 65b195a3c7..0000000000 --- a/cypress/e2e/integration-manager/read_events.spec.ts +++ /dev/null @@ -1,276 +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 { UserCredentials } from "../../support/login"; - -const ROOM_NAME = "Integration Manager Test"; -const USER_DISPLAY_NAME = "Alice"; - -const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; -const INTEGRATION_MANAGER_HTML = ` - - - Fake Integration Manager - - - - - - - -

No response

- - - -`; - -function openIntegrationManager() { - cy.findByRole("button", { name: "Room info" }).click(); - cy.get(".mx_RoomSummaryCard_appsGroup").within(() => { - cy.findByRole("button", { name: "Add widgets, bridges & bots" }).click(); - }); -} - -function sendActionFromIntegrationManager( - integrationManagerUrl: string, - targetRoomId: string, - eventType: string, - stateKey: string | boolean, -) { - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#target-room-id").should("exist").type(targetRoomId); - cy.get("#event-type").should("exist").type(eventType); - cy.get("#state-key").should("exist").type(JSON.stringify(stateKey)); - cy.get("#send-action").should("exist").click(); - }); -} - -describe("Integration Manager: Read Events", () => { - let testUser: UserCredentials; - let homeserver: HomeserverInstance; - let integrationManagerUrl: string; - - beforeEach(() => { - cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => { - integrationManagerUrl = url; - }); - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, USER_DISPLAY_NAME, () => { - cy.window().then((win) => { - win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN); - win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN); - }); - }).then((user) => { - testUser = user; - }); - - cy.setAccountData("m.widgets", { - "m.integration_manager": { - content: { - type: "m.integration_manager", - name: "Integration Manager", - url: integrationManagerUrl, - data: { - api_url: integrationManagerUrl, - }, - }, - id: "integration-manager", - }, - }).as("integrationManager"); - - // Succeed when checking the token is valid - cy.intercept(`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, (req) => { - req.continue((res) => { - return res.send(200, { - user_id: testUser.userId, - }); - }); - }); - - cy.createRoom({ - name: ROOM_NAME, - }).as("roomId"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.stopWebServers(); - }); - - it("should read a state event by state key", () => { - cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { - cy.viewRoomByName(ROOM_NAME); - - const eventType = "io.element.integrations.installations"; - const eventContent = { - foo: "bar", - }; - const stateKey = "state-key-123"; - - // Send a state event - cy.getClient() - .then(async (client) => { - return await client.sendStateEvent(roomId, eventType, eventContent, stateKey); - }) - .then((event) => { - openIntegrationManager(); - - // Read state events - sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey); - - // Check the response - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response") - .should("include.text", event.event_id) - .should("include.text", `"content":${JSON.stringify(eventContent)}`); - }); - }); - }); - }); - - it("should read a state event with empty state key", () => { - cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { - cy.viewRoomByName(ROOM_NAME); - - const eventType = "io.element.integrations.installations"; - const eventContent = { - foo: "bar", - }; - const stateKey = ""; - - // Send a state event - cy.getClient() - .then(async (client) => { - return await client.sendStateEvent(roomId, eventType, eventContent, stateKey); - }) - .then((event) => { - openIntegrationManager(); - - // Read state events - sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey); - - // Check the response - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response") - .should("include.text", event.event_id) - .should("include.text", `"content":${JSON.stringify(eventContent)}`); - }); - }); - }); - }); - - it("should read state events with any state key", () => { - cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { - cy.viewRoomByName(ROOM_NAME); - - const eventType = "io.element.integrations.installations"; - - const stateKey1 = "state-key-123"; - const eventContent1 = { - foo1: "bar1", - }; - const stateKey2 = "state-key-456"; - const eventContent2 = { - foo2: "bar2", - }; - const stateKey3 = "state-key-789"; - const eventContent3 = { - foo3: "bar3", - }; - - // Send state events - cy.getClient() - .then(async (client) => { - return Promise.all([ - client.sendStateEvent(roomId, eventType, eventContent1, stateKey1), - client.sendStateEvent(roomId, eventType, eventContent2, stateKey2), - client.sendStateEvent(roomId, eventType, eventContent3, stateKey3), - ]); - }) - .then((events) => { - openIntegrationManager(); - - // Read state events - sendActionFromIntegrationManager( - integrationManagerUrl, - roomId, - eventType, - true, // Any state key - ); - - // Check the response - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response") - .should("include.text", events[0].event_id) - .should("include.text", `"content":${JSON.stringify(eventContent1)}`) - .should("include.text", events[1].event_id) - .should("include.text", `"content":${JSON.stringify(eventContent2)}`) - .should("include.text", events[2].event_id) - .should("include.text", `"content":${JSON.stringify(eventContent3)}`); - }); - }); - }); - }); - - it("should fail to read an event type which is not allowed", () => { - cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { - cy.viewRoomByName(ROOM_NAME); - - const eventType = "com.example.event"; - const stateKey = ""; - - openIntegrationManager(); - - // Read state events - sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey); - - // Check the response - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response").should("include.text", "Failed to read events"); - }); - }); - }); -}); diff --git a/cypress/e2e/integration-manager/send_event.spec.ts b/cypress/e2e/integration-manager/send_event.spec.ts deleted file mode 100644 index d8a746b423..0000000000 --- a/cypress/e2e/integration-manager/send_event.spec.ts +++ /dev/null @@ -1,261 +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 { UserCredentials } from "../../support/login"; - -const ROOM_NAME = "Integration Manager Test"; -const USER_DISPLAY_NAME = "Alice"; - -const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; -const INTEGRATION_MANAGER_HTML = ` - - - Fake Integration Manager - - - - - - - - -

No response

- - - -`; - -function openIntegrationManager() { - cy.findByRole("button", { name: "Room info" }).click(); - cy.get(".mx_RoomSummaryCard_appsGroup").within(() => { - cy.findByRole("button", { name: "Add widgets, bridges & bots" }).click(); - }); -} - -function sendActionFromIntegrationManager( - integrationManagerUrl: string, - targetRoomId: string, - eventType: string, - stateKey: string, - content: Record, -) { - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#target-room-id").should("exist").type(targetRoomId); - cy.get("#event-type").should("exist").type(eventType); - if (stateKey) { - cy.get("#state-key").should("exist").type(stateKey); - } - cy.get("#event-content").should("exist").type(JSON.stringify(content), { parseSpecialCharSequences: false }); - cy.get("#send-action").should("exist").click(); - }); -} - -describe("Integration Manager: Send Event", () => { - let testUser: UserCredentials; - let homeserver: HomeserverInstance; - let integrationManagerUrl: string; - - beforeEach(() => { - cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => { - integrationManagerUrl = url; - }); - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, USER_DISPLAY_NAME, () => { - cy.window().then((win) => { - win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN); - win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN); - }); - }).then((user) => { - testUser = user; - }); - - cy.setAccountData("m.widgets", { - "m.integration_manager": { - content: { - type: "m.integration_manager", - name: "Integration Manager", - url: integrationManagerUrl, - data: { - api_url: integrationManagerUrl, - }, - }, - id: "integration-manager", - }, - }).as("integrationManager"); - - // Succeed when checking the token is valid - cy.intercept(`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, (req) => { - req.continue((res) => { - return res.send(200, { - user_id: testUser.userId, - }); - }); - }); - - cy.createRoom({ - name: ROOM_NAME, - }).as("roomId"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.stopWebServers(); - }); - - it("should send a state event", () => { - cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { - cy.viewRoomByName(ROOM_NAME); - - openIntegrationManager(); - - const eventType = "io.element.integrations.installations"; - const eventContent = { - foo: "bar", - }; - const stateKey = "state-key-123"; - - // Send the event - sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent); - - // Check the response - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response").should("include.text", "event_id"); - }); - - // Check the event - cy.getClient() - .then(async (client) => { - return await client.getStateEvent(roomId, eventType, stateKey); - }) - .then((event) => { - expect(event).to.deep.equal(eventContent); - }); - }); - }); - - it("should send a state event with empty content", () => { - cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { - cy.viewRoomByName(ROOM_NAME); - - openIntegrationManager(); - - const eventType = "io.element.integrations.installations"; - const eventContent = {}; - const stateKey = "state-key-123"; - - // Send the event - sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent); - - // Check the response - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response").should("include.text", "event_id"); - }); - - // Check the event - cy.getClient() - .then(async (client) => { - return await client.getStateEvent(roomId, eventType, stateKey); - }) - .then((event) => { - expect(event).to.be.empty; - }); - }); - }); - - it("should send a state event with empty state key", () => { - cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { - cy.viewRoomByName(ROOM_NAME); - - openIntegrationManager(); - - const eventType = "io.element.integrations.installations"; - const eventContent = { - foo: "bar", - }; - const stateKey = ""; - - // Send the event - sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent); - - // Check the response - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response").should("include.text", "event_id"); - }); - - // Check the event - cy.getClient() - .then(async (client) => { - return await client.getStateEvent(roomId, eventType, stateKey); - }) - .then((event) => { - expect(event).to.deep.equal(eventContent); - }); - }); - }); - - it("should fail to send an event type which is not allowed", () => { - cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { - cy.viewRoomByName(ROOM_NAME); - - openIntegrationManager(); - - const eventType = "com.example.event"; - const eventContent = { - foo: "bar", - }; - const stateKey = ""; - - // Send the event - sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent); - - // Check the response - cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { - cy.get("#message-response").should("include.text", "Failed to send event"); - }); - }); - }); -}); diff --git a/cypress/e2e/widgets/events.spec.ts b/cypress/e2e/widgets/events.spec.ts deleted file mode 100644 index 58e4c09679..0000000000 --- a/cypress/e2e/widgets/events.spec.ts +++ /dev/null @@ -1,200 +0,0 @@ -/* -Copyright 2022 Mikhail Aheichyk -Copyright 2022 Nordeck IT + Consulting GmbH. - -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 { IWidget } from "matrix-widget-api/src/interfaces/IWidget"; - -import type { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { UserCredentials } from "../../support/login"; -import { waitForRoom } from "../utils"; - -const DEMO_WIDGET_ID = "demo-widget-id"; -const DEMO_WIDGET_NAME = "Demo Widget"; -const DEMO_WIDGET_TYPE = "demo"; -const ROOM_NAME = "Demo"; - -const DEMO_WIDGET_HTML = ` - - - Demo Widget - - - - - - -`; - -describe("Widget Events", () => { - let homeserver: HomeserverInstance; - let user: UserCredentials; - let bot: MatrixClient; - let demoWidgetUrl: string; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Mike").then((_user) => { - user = _user; - }); - cy.getBot(homeserver, { displayName: "Bot", autoAcceptInvites: true }).then((_bot) => { - bot = _bot; - }); - }); - cy.serveHtmlFile(DEMO_WIDGET_HTML).then((url) => { - demoWidgetUrl = url; - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.stopWebServers(); - }); - - it("should be updated if user is re-invited into the room with updated state event", () => { - cy.createRoom({ - name: ROOM_NAME, - invite: [bot.getUserId()], - }).then((roomId) => { - // setup widget via state event - cy.getClient() - .then(async (matrixClient) => { - const content: IWidget = { - id: DEMO_WIDGET_ID, - creatorUserId: "somebody", - type: DEMO_WIDGET_TYPE, - name: DEMO_WIDGET_NAME, - url: demoWidgetUrl, - }; - await matrixClient.sendStateEvent(roomId, "im.vector.modular.widgets", content, DEMO_WIDGET_ID); - }) - .as("widgetEventSent"); - - // set initial layout - cy.getClient() - .then(async (matrixClient) => { - const content = { - widgets: { - [DEMO_WIDGET_ID]: { - container: "top", - index: 1, - width: 100, - height: 0, - }, - }, - }; - await matrixClient.sendStateEvent(roomId, "io.element.widgets.layout", content, ""); - }) - .as("layoutEventSent"); - - // open the room - cy.viewRoomByName(ROOM_NAME); - - // approve capabilities - cy.get(".mx_WidgetCapabilitiesPromptDialog").within(() => { - cy.findByRole("button", { name: "Approve" }).click(); - }); - - cy.all([cy.get("@widgetEventSent"), cy.get("@layoutEventSent")]).then(async () => { - // bot creates a new room with 'm.room.topic' - const { room_id: roomNew } = await bot.createRoom({ - name: "New room", - initial_state: [ - { - type: "m.room.topic", - state_key: "", - content: { - topic: "topic initial", - }, - }, - ], - }); - - await bot.invite(roomNew, user.userId); - - // widget should receive 'm.room.topic' event after invite - cy.window().then(async (win) => { - await waitForRoom(win, win.mxMatrixClientPeg.get(), roomId, (room) => { - const events = room.getLiveTimeline().getEvents(); - return events.some( - (e) => - e.getType() === "net.widget_echo" && - e.getContent().type === "m.room.topic" && - e.getContent().content.topic === "topic initial", - ); - }); - }); - - // update the topic - await bot.sendStateEvent( - roomNew, - "m.room.topic", - { - topic: "topic updated", - }, - "", - ); - - await bot.invite(roomNew, user.userId, "something changed in the room"); - - // widget should receive updated 'm.room.topic' event after re-invite - cy.window().then(async (win) => { - await waitForRoom(win, win.mxMatrixClientPeg.get(), roomId, (room) => { - const events = room.getLiveTimeline().getEvents(); - return events.some( - (e) => - e.getType() === "net.widget_echo" && - e.getContent().type === "m.room.topic" && - e.getContent().content.topic === "topic updated", - ); - }); - }); - }); - }); - }); -}); diff --git a/cypress/e2e/widgets/layout.spec.ts b/cypress/e2e/widgets/layout.spec.ts deleted file mode 100644 index 16470fd5a0..0000000000 --- a/cypress/e2e/widgets/layout.spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -/* -Copyright 2022 Oliver Sand -Copyright 2022 Nordeck IT + Consulting GmbH. - -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 { IWidget } from "matrix-widget-api"; - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; - -const ROOM_NAME = "Test Room"; -const WIDGET_ID = "fake-widget"; -const WIDGET_HTML = ` - - - Fake Widget - - - Hello World - - -`; - -describe("Widget Layout", () => { - let widgetUrl: string; - let homeserver: HomeserverInstance; - let roomId: string; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Sally"); - }); - cy.serveHtmlFile(WIDGET_HTML).then((url) => { - widgetUrl = url; - }); - - cy.createRoom({ - name: ROOM_NAME, - }).then((id) => { - roomId = id; - - // setup widget via state event - cy.getClient() - .then(async (matrixClient) => { - const content: IWidget = { - id: WIDGET_ID, - creatorUserId: "somebody", - type: "widget", - name: "widget", - url: widgetUrl, - }; - await matrixClient.sendStateEvent(roomId, "im.vector.modular.widgets", content, WIDGET_ID); - }) - .as("widgetEventSent"); - - // set initial layout - cy.getClient() - .then(async (matrixClient) => { - const content = { - widgets: { - [WIDGET_ID]: { - container: "top", - index: 1, - width: 100, - height: 0, - }, - }, - }; - await matrixClient.sendStateEvent(roomId, "io.element.widgets.layout", content, ""); - }) - .as("layoutEventSent"); - }); - - cy.all([cy.get("@widgetEventSent"), cy.get("@layoutEventSent")]).then(() => { - // open the room - cy.viewRoomByName(ROOM_NAME); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.stopWebServers(); - }); - - it("should be set properly", () => { - cy.get(".mx_AppsDrawer").percySnapshotElement("Widgets drawer on the timeline (AppsDrawer)"); - }); - - it("manually resize the height of the top container layout", () => { - cy.get('iframe[title="widget"]').invoke("height").should("be.lessThan", 250); - - cy.get(".mx_AppsDrawer_resizer_container_handle") - .trigger("mousedown") - .trigger("mousemove", { clientX: 0, clientY: 550, force: true }) - .trigger("mouseup", { clientX: 0, clientY: 550, force: true }); - - cy.get('iframe[title="widget"]').invoke("height").should("be.greaterThan", 400); - }); - - it("programatically resize the height of the top container layout", () => { - cy.get('iframe[title="widget"]').invoke("height").should("be.lessThan", 250); - - cy.getClient().then(async (matrixClient) => { - const content = { - widgets: { - [WIDGET_ID]: { - container: "top", - index: 1, - width: 100, - height: 100, - }, - }, - }; - await matrixClient.sendStateEvent(roomId, "io.element.widgets.layout", content, ""); - }); - - cy.get('iframe[title="widget"]').invoke("height").should("be.greaterThan", 400); - }); -}); diff --git a/cypress/e2e/widgets/widget-pip-close.spec.ts b/cypress/e2e/widgets/widget-pip-close.spec.ts deleted file mode 100644 index ca717947d0..0000000000 --- a/cypress/e2e/widgets/widget-pip-close.spec.ts +++ /dev/null @@ -1,207 +0,0 @@ -/* -Copyright 2022 Mikhail Aheichyk -Copyright 2022 Nordeck IT + Consulting GmbH. - -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 { IWidget } from "matrix-widget-api/src/interfaces/IWidget"; - -import type { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { UserCredentials } from "../../support/login"; - -const DEMO_WIDGET_ID = "demo-widget-id"; -const DEMO_WIDGET_NAME = "Demo Widget"; -const DEMO_WIDGET_TYPE = "demo"; -const ROOM_NAME = "Demo"; - -const DEMO_WIDGET_HTML = ` - - - Demo Widget - - - - - - -`; - -// mostly copied from src/utils/WidgetUtils.waitForRoomWidget with small modifications -function waitForRoomWidget(win: Cypress.AUTWindow, widgetId: string, roomId: string, add: boolean): Promise { - const matrixClient = win.mxMatrixClientPeg.get(); - - return new Promise((resolve, reject) => { - function eventsInIntendedState(evList) { - const widgetPresent = evList.some((ev) => { - return ev.getContent() && ev.getContent()["id"] === widgetId; - }); - if (add) { - return widgetPresent; - } else { - return !widgetPresent; - } - } - - const room = matrixClient.getRoom(roomId); - - const startingWidgetEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); - if (eventsInIntendedState(startingWidgetEvents)) { - resolve(); - return; - } - - function onRoomStateEvents(ev: MatrixEvent) { - if (ev.getRoomId() !== roomId || ev.getType() !== "im.vector.modular.widgets") return; - - const currentWidgetEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); - - if (eventsInIntendedState(currentWidgetEvents)) { - matrixClient.removeListener(win.matrixcs.RoomStateEvent.Events, onRoomStateEvents); - resolve(); - } - } - - matrixClient.on(win.matrixcs.RoomStateEvent.Events, onRoomStateEvents); - }); -} - -describe("Widget PIP", () => { - let homeserver: HomeserverInstance; - let user: UserCredentials; - let bot: MatrixClient; - let demoWidgetUrl: string; - - function roomCreateAddWidgetPip(userRemove: "leave" | "kick" | "ban") { - cy.createRoom({ - name: ROOM_NAME, - invite: [bot.getUserId()], - }).then((roomId) => { - // sets bot to Admin and user to Moderator - cy.getClient() - .then((matrixClient) => { - return matrixClient.sendStateEvent(roomId, "m.room.power_levels", { - users: { - [user.userId]: 50, - [bot.getUserId()]: 100, - }, - }); - }) - .as("powerLevelsChanged"); - - // bot joins the room - cy.botJoinRoom(bot, roomId).as("botJoined"); - - // setup widget via state event - cy.getClient() - .then(async (matrixClient) => { - const content: IWidget = { - id: DEMO_WIDGET_ID, - creatorUserId: "somebody", - type: DEMO_WIDGET_TYPE, - name: DEMO_WIDGET_NAME, - url: demoWidgetUrl, - }; - await matrixClient.sendStateEvent(roomId, "im.vector.modular.widgets", content, DEMO_WIDGET_ID); - }) - .as("widgetEventSent"); - - // open the room - cy.viewRoomByName(ROOM_NAME); - - cy.all([ - cy.get("@powerLevelsChanged"), - cy.get("@botJoined"), - cy.get("@widgetEventSent"), - ]).then(() => { - cy.window().then(async (win) => { - // wait for widget state event - await waitForRoomWidget(win, DEMO_WIDGET_ID, roomId, true); - - // activate widget in pip mode - win.mxActiveWidgetStore.setWidgetPersistence(DEMO_WIDGET_ID, roomId, true); - - // checks that pip window is opened - cy.get(".mx_WidgetPip").should("exist"); - - // checks that widget is opened in pip - cy.accessIframe(`iframe[title="${DEMO_WIDGET_NAME}"]`).within({}, () => { - cy.get("#demo") - .should("exist") - .then(async () => { - const userId = user.userId; - if (userRemove == "leave") { - cy.getClient().then(async (matrixClient) => { - await matrixClient.leave(roomId); - }); - } else if (userRemove == "kick") { - await bot.kick(roomId, userId); - } else if (userRemove == "ban") { - await bot.ban(roomId, userId); - } - - // checks that pip window is closed - cy.get(".mx_WidgetPip").should("not.exist"); - }); - }); - }); - }); - }); - } - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Mike").then((_user) => { - user = _user; - }); - cy.getBot(homeserver, { displayName: "Bot", autoAcceptInvites: false }).then((_bot) => { - bot = _bot; - }); - }); - cy.serveHtmlFile(DEMO_WIDGET_HTML).then((url) => { - demoWidgetUrl = url; - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.stopWebServers(); - }); - - it("should be closed on leave", () => { - roomCreateAddWidgetPip("leave"); - }); - - it("should be closed on kick", () => { - roomCreateAddWidgetPip("kick"); - }); - - it("should be closed on ban", () => { - roomCreateAddWidgetPip("ban"); - }); -}); diff --git a/playwright/e2e/integration-manager/get-openid-token.spec.ts b/playwright/e2e/integration-manager/get-openid-token.spec.ts new file mode 100644 index 0000000000..c107bb2cbc --- /dev/null +++ b/playwright/e2e/integration-manager/get-openid-token.spec.ts @@ -0,0 +1,128 @@ +/* +Copyright 2022 - 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 } from "@playwright/test"; +import { test, expect } from "../../element-web-test"; +import { openIntegrationManager } from "./utils"; + +const ROOM_NAME = "Integration Manager Test"; + +const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; +const INTEGRATION_MANAGER_HTML = ` + + + Fake Integration Manager + + + + +

No response

+ + + +`; + +async function sendActionFromIntegrationManager(page: Page, integrationManagerUrl: string) { + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await iframe.getByRole("button", { name: "Press to send action" }).click(); +} + +test.describe("Integration Manager: Get OpenID Token", () => { + test.use({ + displayName: "Alice", + room: async ({ user, app }, use) => { + const roomId = await app.client.createRoom({ + name: ROOM_NAME, + }); + await use({ roomId }); + }, + }); + + let integrationManagerUrl: string; + test.beforeEach(async ({ page, webserver }) => { + integrationManagerUrl = webserver.start(INTEGRATION_MANAGER_HTML); + + await page.addInitScript( + ({ token, integrationManagerUrl }) => { + window.localStorage.setItem("mx_scalar_token", token); + window.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, token); + }, + { + token: INTEGRATION_MANAGER_TOKEN, + integrationManagerUrl, + }, + ); + }); + + test.beforeEach(async ({ page, user, app, room }) => { + await app.client.setAccountData("m.widgets", { + "m.integration_manager": { + content: { + type: "m.integration_manager", + name: "Integration Manager", + url: integrationManagerUrl, + data: { + api_url: integrationManagerUrl, + }, + }, + id: "integration-manager", + }, + }); + + // Succeed when checking the token is valid + await page.route( + `${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, + async (route) => { + await route.fulfill({ + json: { + user_id: user.userId, + }, + }); + }, + ); + + await app.viewRoomByName(ROOM_NAME); + }); + + test("should successfully obtain an openID token", async ({ page }) => { + await openIntegrationManager(page); + await sendActionFromIntegrationManager(page, integrationManagerUrl); + + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response").getByText(/access_token/)).toBeVisible(); + }); +}); diff --git a/playwright/e2e/integration-manager/kick.spec.ts b/playwright/e2e/integration-manager/kick.spec.ts new file mode 100644 index 0000000000..b5ca6a1b3a --- /dev/null +++ b/playwright/e2e/integration-manager/kick.spec.ts @@ -0,0 +1,226 @@ +/* +Copyright 2022 - 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 } from "@playwright/test"; +import { test, expect } from "../../element-web-test"; +import { openIntegrationManager } from "./utils"; + +const ROOM_NAME = "Integration Manager Test"; +const USER_DISPLAY_NAME = "Alice"; +const BOT_DISPLAY_NAME = "Bob"; +const KICK_REASON = "Goodbye"; + +const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; +const INTEGRATION_MANAGER_HTML = ` + + + Fake Integration Manager + + + + + + + + + +`; + +async function closeIntegrationManager(page: Page, integrationManagerUrl: string) { + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await iframe.getByRole("button", { name: "Press to close" }).click(); +} + +async function sendActionFromIntegrationManager( + page: Page, + integrationManagerUrl: string, + targetRoomId: string, + targetUserId: string, +) { + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await iframe.locator("#target-room-id").fill(targetRoomId); + await iframe.locator("#target-user-id").fill(targetUserId); + await iframe.getByRole("button", { name: "Press to send action" }).click(); +} + +async function clickUntilGone(page: Page, selector: string, attempt = 0) { + if (attempt === 11) { + throw new Error("clickUntilGone attempt count exceeded"); + } + + await page.locator(selector).last().click(); + + const count = await page.locator(selector).count(); + if (count > 0) { + return clickUntilGone(page, selector, ++attempt); + } +} + +async function expectKickedMessage(page: Page, shouldExist: boolean) { + // Expand any event summaries, we can't use a click multiple here because clicking one might de-render others + // This is quite horrible but seems the most stable way of clicking 0-N buttons, + // one at a time with a full re-evaluation after each click + await clickUntilGone(page, ".mx_GenericEventListSummary_toggle[aria-expanded=false]"); + + // Check for the event message (or lack thereof) + await expect(page.getByText(`${USER_DISPLAY_NAME} removed ${BOT_DISPLAY_NAME}: ${KICK_REASON}`)).toBeVisible({ + visible: shouldExist, + }); +} + +test.describe("Integration Manager: Kick", () => { + test.use({ + displayName: "Alice", + room: async ({ user, app }, use) => { + const roomId = await app.client.createRoom({ + name: ROOM_NAME, + }); + await use({ roomId }); + }, + botCreateOpts: { + displayName: BOT_DISPLAY_NAME, + autoAcceptInvites: true, + }, + }); + + let integrationManagerUrl: string; + test.beforeEach(async ({ page, webserver }) => { + integrationManagerUrl = webserver.start(INTEGRATION_MANAGER_HTML); + + await page.addInitScript( + ({ token, integrationManagerUrl }) => { + window.localStorage.setItem("mx_scalar_token", token); + window.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, token); + }, + { + token: INTEGRATION_MANAGER_TOKEN, + integrationManagerUrl, + }, + ); + }); + + test.beforeEach(async ({ page, user, app, room }) => { + await app.client.setAccountData("m.widgets", { + "m.integration_manager": { + content: { + type: "m.integration_manager", + name: "Integration Manager", + url: integrationManagerUrl, + data: { + api_url: integrationManagerUrl, + }, + }, + id: "integration-manager", + }, + }); + + // Succeed when checking the token is valid + await page.route( + `${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, + async (route) => { + await route.fulfill({ + json: { + user_id: user.userId, + }, + }); + }, + ); + + await app.viewRoomByName(ROOM_NAME); + }); + + test("should kick the target", async ({ page, app, bot: targetUser, room }) => { + await app.viewRoomByName(ROOM_NAME); + await app.client.inviteUser(room.roomId, targetUser.credentials.userId); + await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible(); + + await openIntegrationManager(page); + await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); + await closeIntegrationManager(page, integrationManagerUrl); + await expectKickedMessage(page, true); + }); + + test("should not kick the target if lacking permissions", async ({ page, app, user, bot: targetUser, room }) => { + await app.viewRoomByName(ROOM_NAME); + await app.client.inviteUser(room.roomId, targetUser.credentials.userId); + await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible(); + + await app.client.sendStateEvent(room.roomId, "m.room.power_levels", { + kick: 50, + users: { + [user.userId]: 0, + }, + }); + + await openIntegrationManager(page); + await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); + await closeIntegrationManager(page, integrationManagerUrl); + await expectKickedMessage(page, false); + }); + + test("should no-op if the target already left", async ({ page, app, bot: targetUser, room }) => { + await app.viewRoomByName(ROOM_NAME); + await app.client.inviteUser(room.roomId, targetUser.credentials.userId); + await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible(); + await targetUser.leave(room.roomId); + + await openIntegrationManager(page); + await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); + await closeIntegrationManager(page, integrationManagerUrl); + await expectKickedMessage(page, false); + }); + + test("should no-op if the target was banned", async ({ page, app, bot: targetUser, room }) => { + await app.viewRoomByName(ROOM_NAME); + await app.client.inviteUser(room.roomId, targetUser.credentials.userId); + await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible(); + await app.client.ban(room.roomId, targetUser.credentials.userId); + + await openIntegrationManager(page); + await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); + await closeIntegrationManager(page, integrationManagerUrl); + await expectKickedMessage(page, false); + }); + + test("should no-op if the target was never a room member", async ({ page, app, bot: targetUser, room }) => { + await app.viewRoomByName(ROOM_NAME); + + await openIntegrationManager(page); + await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId); + await closeIntegrationManager(page, integrationManagerUrl); + await expectKickedMessage(page, false); + }); +}); diff --git a/playwright/e2e/integration-manager/read_events.spec.ts b/playwright/e2e/integration-manager/read_events.spec.ts new file mode 100644 index 0000000000..b178596674 --- /dev/null +++ b/playwright/e2e/integration-manager/read_events.spec.ts @@ -0,0 +1,233 @@ +/* +Copyright 2022 - 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 } from "@playwright/test"; +import { test, expect } from "../../element-web-test"; +import { openIntegrationManager } from "./utils"; + +const ROOM_NAME = "Integration Manager Test"; + +const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; +const INTEGRATION_MANAGER_HTML = ` + + + Fake Integration Manager + + + + + + + +

No response

+ + + +`; + +async function sendActionFromIntegrationManager( + page: Page, + integrationManagerUrl: string, + targetRoomId: string, + eventType: string, + stateKey: string | boolean, +) { + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await iframe.locator("#target-room-id").fill(targetRoomId); + await iframe.locator("#event-type").fill(eventType); + await iframe.locator("#state-key").fill(JSON.stringify(stateKey)); + await iframe.locator("#send-action").click(); +} + +test.describe("Integration Manager: Read Events", () => { + test.use({ + displayName: "Alice", + room: async ({ user, app }, use) => { + const roomId = await app.client.createRoom({ + name: ROOM_NAME, + }); + await use({ roomId }); + }, + }); + + let integrationManagerUrl: string; + test.beforeEach(async ({ page, webserver }) => { + integrationManagerUrl = webserver.start(INTEGRATION_MANAGER_HTML); + + await page.addInitScript( + ({ token, integrationManagerUrl }) => { + window.localStorage.setItem("mx_scalar_token", token); + window.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, token); + }, + { + token: INTEGRATION_MANAGER_TOKEN, + integrationManagerUrl, + }, + ); + }); + + test.beforeEach(async ({ page, user, app, room }) => { + await app.client.setAccountData("m.widgets", { + "m.integration_manager": { + content: { + type: "m.integration_manager", + name: "Integration Manager", + url: integrationManagerUrl, + data: { + api_url: integrationManagerUrl, + }, + }, + id: "integration-manager", + }, + }); + + // Succeed when checking the token is valid + await page.route( + `${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, + async (route) => { + await route.fulfill({ + json: { + user_id: user.userId, + }, + }); + }, + ); + + await app.viewRoomByName(ROOM_NAME); + }); + + test("should read a state event by state key", async ({ page, app, room }) => { + const eventType = "io.element.integrations.installations"; + const eventContent = { + foo: "bar", + }; + const stateKey = "state-key-123"; + + // Send a state event + const sendEventResponse = await app.client.sendStateEvent(room.roomId, eventType, eventContent, stateKey); + await openIntegrationManager(page); + + // Read state events + await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey); + + // Check the response + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response")).toContainText(sendEventResponse.event_id); + await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent)}`); + }); + + test("should read a state event with empty state key", async ({ page, app, room }) => { + const eventType = "io.element.integrations.installations"; + const eventContent = { + foo: "bar", + }; + const stateKey = ""; + + // Send a state event + const sendEventResponse = await app.client.sendStateEvent(room.roomId, eventType, eventContent, stateKey); + await openIntegrationManager(page); + + // Read state events + await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey); + + // Check the response + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response")).toContainText(sendEventResponse.event_id); + await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent)}`); + }); + + test("should read state events with any state key", async ({ page, app, room }) => { + const eventType = "io.element.integrations.installations"; + + const stateKey1 = "state-key-123"; + const eventContent1 = { + foo1: "bar1", + }; + const stateKey2 = "state-key-456"; + const eventContent2 = { + foo2: "bar2", + }; + const stateKey3 = "state-key-789"; + const eventContent3 = { + foo3: "bar3", + }; + + // Send state events + const sendEventResponses = await Promise.all([ + app.client.sendStateEvent(room.roomId, eventType, eventContent1, stateKey1), + app.client.sendStateEvent(room.roomId, eventType, eventContent2, stateKey2), + app.client.sendStateEvent(room.roomId, eventType, eventContent3, stateKey3), + ]); + + await openIntegrationManager(page); + + // Read state events + await sendActionFromIntegrationManager( + page, + integrationManagerUrl, + room.roomId, + eventType, + true, // Any state key + ); + + // Check the response + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response")).toContainText(sendEventResponses[0].event_id); + await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent1)}`); + await expect(iframe.locator("#message-response")).toContainText(sendEventResponses[1].event_id); + await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent2)}`); + await expect(iframe.locator("#message-response")).toContainText(sendEventResponses[2].event_id); + await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent3)}`); + }); + + test("should fail to read an event type which is not allowed", async ({ page, room }) => { + const eventType = "com.example.event"; + const stateKey = ""; + + await openIntegrationManager(page); + + // Read state events + await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey); + + // Check the response + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response")).toContainText("Failed to read events"); + }); +}); diff --git a/playwright/e2e/integration-manager/send_event.spec.ts b/playwright/e2e/integration-manager/send_event.spec.ts new file mode 100644 index 0000000000..61bad8a3ec --- /dev/null +++ b/playwright/e2e/integration-manager/send_event.spec.ts @@ -0,0 +1,255 @@ +/* +Copyright 2022 - 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 } from "@playwright/test"; +import { test, expect } from "../../element-web-test"; +import { openIntegrationManager } from "./utils"; + +const ROOM_NAME = "Integration Manager Test"; + +const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; +const INTEGRATION_MANAGER_HTML = ` + + + Fake Integration Manager + + + + + + + + +

No response

+ + + +`; + +async function sendActionFromIntegrationManager( + page: Page, + integrationManagerUrl: string, + targetRoomId: string, + eventType: string, + stateKey: string, + content: Record, +) { + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await iframe.locator("#target-room-id").fill(targetRoomId); + await iframe.locator("#event-type").fill(eventType); + if (stateKey) { + await iframe.locator("#state-key").fill(stateKey); + } + await iframe.locator("#event-content").fill(JSON.stringify(content)); + await iframe.locator("#send-action").click(); +} + +test.describe("Integration Manager: Send Event", () => { + test.use({ + displayName: "Alice", + room: async ({ user, app }, use) => { + const roomId = await app.client.createRoom({ + name: ROOM_NAME, + }); + await use({ roomId }); + }, + }); + + let integrationManagerUrl: string; + test.beforeEach(async ({ page, webserver }) => { + integrationManagerUrl = webserver.start(INTEGRATION_MANAGER_HTML); + + await page.addInitScript( + ({ token, integrationManagerUrl }) => { + window.localStorage.setItem("mx_scalar_token", token); + window.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, token); + }, + { + token: INTEGRATION_MANAGER_TOKEN, + integrationManagerUrl, + }, + ); + }); + + test.beforeEach(async ({ page, user, app, room }) => { + await app.client.setAccountData("m.widgets", { + "m.integration_manager": { + content: { + type: "m.integration_manager", + name: "Integration Manager", + url: integrationManagerUrl, + data: { + api_url: integrationManagerUrl, + }, + }, + id: "integration-manager", + }, + }); + + // Succeed when checking the token is valid + await page.route( + `${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, + async (route) => { + await route.fulfill({ + json: { + user_id: user.userId, + }, + }); + }, + ); + + await app.viewRoomByName(ROOM_NAME); + await openIntegrationManager(page); + }); + + test("should send a state event", async ({ page, app, room }) => { + const eventType = "io.element.integrations.installations"; + const eventContent = { + foo: "bar", + }; + const stateKey = "state-key-123"; + + // Send the event + await sendActionFromIntegrationManager( + page, + integrationManagerUrl, + room.roomId, + eventType, + stateKey, + eventContent, + ); + + // Check the response + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response")).toContainText("event_id"); + + // Check the event + const event = await app.client.evaluate( + (cli, { room, eventType, stateKey }) => { + return cli.getStateEvent(room.roomId, eventType, stateKey); + }, + { room, eventType, stateKey }, + ); + expect(event).toMatchObject(eventContent); + }); + + test("should send a state event with empty content", async ({ page, app, room }) => { + const eventType = "io.element.integrations.installations"; + const eventContent = {}; + const stateKey = "state-key-123"; + + // Send the event + await sendActionFromIntegrationManager( + page, + integrationManagerUrl, + room.roomId, + eventType, + stateKey, + eventContent, + ); + + // Check the response + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response")).toContainText("event_id"); + + // Check the event + const event = await app.client.evaluate( + (cli, { room, eventType, stateKey }) => { + return cli.getStateEvent(room.roomId, eventType, stateKey); + }, + { room, eventType, stateKey }, + ); + expect(event).toMatchObject({}); + }); + + test("should send a state event with empty state key", async ({ page, app, room }) => { + const eventType = "io.element.integrations.installations"; + const eventContent = { + foo: "bar", + }; + const stateKey = ""; + + // Send the event + await sendActionFromIntegrationManager( + page, + integrationManagerUrl, + room.roomId, + eventType, + stateKey, + eventContent, + ); + + // Check the response + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response")).toContainText("event_id"); + + // Check the event + const event = await app.client.evaluate( + (cli, { room, eventType, stateKey }) => { + return cli.getStateEvent(room.roomId, eventType, stateKey); + }, + { room, eventType, stateKey }, + ); + expect(event).toMatchObject(eventContent); + }); + + test("should fail to send an event type which is not allowed", async ({ page, room }) => { + const eventType = "com.example.event"; + const eventContent = { + foo: "bar", + }; + const stateKey = ""; + + // Send the event + await sendActionFromIntegrationManager( + page, + integrationManagerUrl, + room.roomId, + eventType, + stateKey, + eventContent, + ); + + // Check the response + const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`); + await expect(iframe.locator("#message-response")).toContainText("Failed to send event"); + }); +}); diff --git a/playwright/e2e/integration-manager/utils.ts b/playwright/e2e/integration-manager/utils.ts new file mode 100644 index 0000000000..259ff732c7 --- /dev/null +++ b/playwright/e2e/integration-manager/utils.ts @@ -0,0 +1,25 @@ +/* +Copyright 2022 - 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 } from "@playwright/test"; + +export async function openIntegrationManager(page: Page) { + await page.getByRole("button", { name: "Room info" }).click(); + await page + .locator(".mx_RoomSummaryCard_appsGroup") + .getByRole("button", { name: "Add widgets, bridges & bots" }) + .click(); +} diff --git a/playwright/e2e/widgets/events.spec.ts b/playwright/e2e/widgets/events.spec.ts new file mode 100644 index 0000000000..a336bd2cfa --- /dev/null +++ b/playwright/e2e/widgets/events.spec.ts @@ -0,0 +1,176 @@ +/* +Copyright 2022 Mikhail Aheichyk +Copyright 2022 Nordeck IT + Consulting GmbH. +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 { test } from "../../element-web-test"; +import { waitForRoom } from "../utils"; + +const DEMO_WIDGET_ID = "demo-widget-id"; +const DEMO_WIDGET_NAME = "Demo Widget"; +const DEMO_WIDGET_TYPE = "demo"; +const ROOM_NAME = "Demo"; + +const DEMO_WIDGET_HTML = ` + + + Demo Widget + + + + + + +`; + +test.describe("Widget Events", () => { + test.use({ + displayName: "Mike", + botCreateOpts: { displayName: "Bot", autoAcceptInvites: true }, + }); + + let demoWidgetUrl: string; + test.beforeEach(async ({ webserver }) => { + demoWidgetUrl = webserver.start(DEMO_WIDGET_HTML); + }); + + test("should be updated if user is re-invited into the room with updated state event", async ({ + page, + app, + user, + bot, + }) => { + const roomId = await app.client.createRoom({ + name: ROOM_NAME, + invite: [bot.credentials.userId], + }); + + // setup widget via state event + await app.client.sendStateEvent( + roomId, + "im.vector.modular.widgets", + { + id: DEMO_WIDGET_ID, + creatorUserId: "somebody", + type: DEMO_WIDGET_TYPE, + name: DEMO_WIDGET_NAME, + url: demoWidgetUrl, + }, + DEMO_WIDGET_ID, + ); + + // set initial layout + await app.client.sendStateEvent( + roomId, + "io.element.widgets.layout", + { + widgets: { + [DEMO_WIDGET_ID]: { + container: "top", + index: 1, + width: 100, + height: 0, + }, + }, + }, + "", + ); + + // open the room + await app.viewRoomByName(ROOM_NAME); + + // approve capabilities + await page.locator(".mx_WidgetCapabilitiesPromptDialog").getByRole("button", { name: "Approve" }).click(); + + // bot creates a new room with 'm.room.topic' + const roomNew = await bot.createRoom({ + name: "New room", + initial_state: [ + { + type: "m.room.topic", + state_key: "", + content: { + topic: "topic initial", + }, + }, + ], + }); + + await bot.inviteUser(roomNew, user.userId); + + // widget should receive 'm.room.topic' event after invite + await waitForRoom(page, app.client, roomId, (room) => { + const events = room.getLiveTimeline().getEvents(); + return events.some( + (e) => + e.getType() === "net.widget_echo" && + e.getContent().type === "m.room.topic" && + e.getContent().content.topic === "topic initial", + ); + }); + + // update the topic + await bot.sendStateEvent( + roomNew, + "m.room.topic", + { + topic: "topic updated", + }, + "", + ); + + await bot.inviteUser(roomNew, user.userId); + + // widget should receive updated 'm.room.topic' event after re-invite + await waitForRoom(page, app.client, roomId, (room) => { + const events = room.getLiveTimeline().getEvents(); + return events.some( + (e) => + e.getType() === "net.widget_echo" && + e.getContent().type === "m.room.topic" && + e.getContent().content.topic === "topic updated", + ); + }); + }); +}); diff --git a/playwright/e2e/widgets/layout.spec.ts b/playwright/e2e/widgets/layout.spec.ts new file mode 100644 index 0000000000..a5dd856a93 --- /dev/null +++ b/playwright/e2e/widgets/layout.spec.ts @@ -0,0 +1,119 @@ +/* +Copyright 2022 Oliver Sand +Copyright 2022 Nordeck IT + Consulting GmbH. +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 { test, expect } from "../../element-web-test"; + +const ROOM_NAME = "Test Room"; +const WIDGET_ID = "fake-widget"; +const WIDGET_HTML = ` + + + Fake Widget + + + Hello World + + +`; + +test.describe("Widget Layout", () => { + test.use({ + displayName: "Sally", + }); + + let roomId: string; + let widgetUrl: string; + test.beforeEach(async ({ webserver, app, user }) => { + widgetUrl = webserver.start(WIDGET_HTML); + + roomId = await app.client.createRoom({ name: ROOM_NAME }); + + // setup widget via state event + await app.client.sendStateEvent( + roomId, + "im.vector.modular.widgets", + { + id: WIDGET_ID, + creatorUserId: "somebody", + type: "widget", + name: "widget", + url: widgetUrl, + }, + WIDGET_ID, + ); + + // set initial layout + await app.client.sendStateEvent( + roomId, + "io.element.widgets.layout", + { + widgets: { + [WIDGET_ID]: { + container: "top", + index: 1, + width: 100, + height: 0, + }, + }, + }, + "", + ); + + // open the room + await app.viewRoomByName(ROOM_NAME); + }); + + test("should be set properly", async ({ page }) => { + await expect(page.locator(".mx_AppsDrawer")).toMatchScreenshot("apps-drawer.png"); + }); + + test("manually resize the height of the top container layout", async ({ page }) => { + const iframe = page.locator('iframe[title="widget"]'); + expect((await iframe.boundingBox()).height).toBeLessThan(250); + + await page.locator(".mx_AppsDrawer_resizer_container_handle").hover(); + await page.mouse.down(); + await page.mouse.move(0, 550); + await page.mouse.up(); + + expect((await iframe.boundingBox()).height).toBeGreaterThan(400); + }); + + test("programmatically resize the height of the top container layout", async ({ page, app }) => { + const iframe = page.locator('iframe[title="widget"]'); + expect((await iframe.boundingBox()).height).toBeLessThan(250); + + await app.client.sendStateEvent( + roomId, + "io.element.widgets.layout", + { + widgets: { + [WIDGET_ID]: { + container: "top", + index: 1, + width: 100, + height: 500, + }, + }, + }, + "", + ); + + await expect.poll(async () => (await iframe.boundingBox()).height).toBeGreaterThan(400); + }); +}); diff --git a/cypress/e2e/widgets/stickers.spec.ts b/playwright/e2e/widgets/stickers.spec.ts similarity index 52% rename from cypress/e2e/widgets/stickers.spec.ts rename to playwright/e2e/widgets/stickers.spec.ts index d3e08f8405..37aaea58ce 100644 --- a/cypress/e2e/widgets/stickers.spec.ts +++ b/playwright/e2e/widgets/stickers.spec.ts @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022 - 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. @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -/// - -import { HomeserverInstance } from "../../plugins/utils/homeserver"; +import type { Page } from "@playwright/test"; +import { test, expect } from "../../element-web-test"; +import { ElementAppPage } from "../../pages/ElementAppPage"; const STICKER_PICKER_WIDGET_ID = "fake-sticker-picker"; const STICKER_PICKER_WIDGET_NAME = "Fake Stickers"; @@ -33,7 +33,7 @@ const STICKER_MESSAGE = JSON.stringify({ content: { body: STICKER_NAME, msgtype: "m.sticker", - url: "mxc://somewhere", + url: "mxc://localhost/somewhere", }, }, requestId: "1", @@ -66,108 +66,86 @@ const WIDGET_HTML = ` `; -function openStickerPicker() { - cy.openMessageComposerOptions().findByRole("menuitem", { name: "Sticker" }).click(); +async function openStickerPicker(app: ElementAppPage) { + const options = await app.openMessageComposerOptions(); + await options.getByRole("menuitem", { name: "Sticker" }).click(); } -function sendStickerFromPicker() { - // Note: Until https://github.com/cypress-io/cypress/issues/136 is fixed we will need - // to use `chromeWebSecurity: false` in our cypress config. Not even cy.origin() can - // break into the iframe for us :( - cy.accessIframe(`iframe[title="${STICKER_PICKER_WIDGET_NAME}"]`).within({}, () => { - cy.get("#sendsticker").should("exist").click(); - }); +async function sendStickerFromPicker(page: Page) { + const iframe = page.frameLocator(`iframe[title="${STICKER_PICKER_WIDGET_NAME}"]`); + await iframe.locator("#sendsticker").click(); // Sticker picker should close itself after sending. - cy.get(".mx_AppTileFullWidth#stickers").should("not.exist"); + await expect(page.locator(".mx_AppTileFullWidth#stickers")).not.toBeVisible(); } -function expectTimelineSticker(roomId: string) { +async function expectTimelineSticker(page: Page, roomId: string) { // Make sure it's in the right room - cy.get(".mx_EventTile_sticker > a").should("have.attr", "href").and("include", `/${roomId}/`); + await expect(page.locator(".mx_EventTile_sticker > a")).toHaveAttribute("href", new RegExp(`/${roomId}/`)); // Make sure the image points at the sticker image. We will briefly show it // using the thumbnail URL, but as soon as that fails, we will switch to the // download URL. - cy.get(`img[alt="${STICKER_NAME}"][src*="download/somewhere"]`).should("exist"); + await expect(page.locator(`img[alt="${STICKER_NAME}"]`)).toHaveAttribute( + "src", + new RegExp("/download/localhost/somewhere"), + ); } -describe("Stickers", () => { +test.describe("Stickers", () => { + test.use({ + displayName: "Sally", + }); + // We spin up a web server for the sticker picker so that we're not testing to see if // sysadmins can deploy sticker pickers on the same Element domain - we actually want // to make sure that cross-origin postMessage works properly. This makes it difficult // to write the test though, as we have to juggle iframe logistics. // // See sendStickerFromPicker() for more detail on iframe comms. - let stickerPickerUrl: string; - let homeserver: HomeserverInstance; - let userId: string; - - beforeEach(() => { - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Sally").then((user) => (userId = user.userId)); - }); - cy.serveHtmlFile(WIDGET_HTML).then((url) => { - stickerPickerUrl = url; - }); + test.beforeEach(async ({ webserver }) => { + stickerPickerUrl = webserver.start(WIDGET_HTML); }); - afterEach(() => { - cy.stopHomeserver(homeserver); - cy.stopWebServers(); - }); + test("should send a sticker to multiple rooms", async ({ page, app, user }) => { + const roomId1 = await app.client.createRoom({ name: ROOM_NAME_1 }); + const roomId2 = await app.client.createRoom({ name: ROOM_NAME_2 }); - it("should send a sticker to multiple rooms", () => { - cy.createRoom({ - name: ROOM_NAME_1, - }).as("roomId1"); - cy.createRoom({ - name: ROOM_NAME_2, - }).as("roomId2"); - cy.setAccountData("m.widgets", { + await app.client.setAccountData("m.widgets", { [STICKER_PICKER_WIDGET_ID]: { content: { type: "m.stickerpicker", name: STICKER_PICKER_WIDGET_NAME, url: stickerPickerUrl, - creatorUserId: userId, + creatorUserId: user.userId, }, - sender: userId, + sender: user.userId, state_key: STICKER_PICKER_WIDGET_ID, type: "m.widget", id: STICKER_PICKER_WIDGET_ID, }, - }).as("stickers"); - - cy.all([ - cy.get("@roomId1"), - cy.get("@roomId2"), - cy.get<{}>("@stickers"), // just want to wait for it to be set up - ]).then(([roomId1, roomId2]) => { - cy.viewRoomByName(ROOM_NAME_1); - cy.url().should("contain", `/#/room/${roomId1}`); - openStickerPicker(); - sendStickerFromPicker(); - expectTimelineSticker(roomId1); - - // Ensure that when we switch to a different room that the sticker - // goes to the right place - cy.viewRoomByName(ROOM_NAME_2); - cy.url().should("contain", `/#/room/${roomId2}`); - openStickerPicker(); - sendStickerFromPicker(); - expectTimelineSticker(roomId2); }); + + await app.viewRoomByName(ROOM_NAME_1); + await expect(page).toHaveURL(`/#/room/${roomId1}`); + await openStickerPicker(app); + await sendStickerFromPicker(page); + await expectTimelineSticker(page, roomId1); + + // Ensure that when we switch to a different room that the sticker + // goes to the right place + await app.viewRoomByName(ROOM_NAME_2); + await expect(page).toHaveURL(`/#/room/${roomId2}`); + await openStickerPicker(app); + await sendStickerFromPicker(page); + await expectTimelineSticker(page, roomId2); }); - it("should handle a sticker picker widget missing creatorUserId", () => { - cy.createRoom({ - name: ROOM_NAME_1, - }).as("roomId1"); - cy.setAccountData("m.widgets", { + test("should handle a sticker picker widget missing creatorUserId", async ({ page, app, user }) => { + const roomId1 = await app.client.createRoom({ name: ROOM_NAME_1 }); + + await app.client.setAccountData("m.widgets", { [STICKER_PICKER_WIDGET_ID]: { content: { type: "m.stickerpicker", @@ -175,19 +153,17 @@ describe("Stickers", () => { url: stickerPickerUrl, // No creatorUserId }, - sender: userId, + sender: user.userId, state_key: STICKER_PICKER_WIDGET_ID, type: "m.widget", id: STICKER_PICKER_WIDGET_ID, }, - }).as("stickers"); - - cy.all([cy.get("@roomId1"), cy.get<{}>("@stickers")]).then(([roomId1]) => { - cy.viewRoomByName(ROOM_NAME_1); - cy.url().should("contain", `/#/room/${roomId1}`); - openStickerPicker(); - sendStickerFromPicker(); - expectTimelineSticker(roomId1); }); + + await app.viewRoomByName(ROOM_NAME_1); + await expect(page).toHaveURL(`/#/room/${roomId1}`); + await openStickerPicker(app); + await sendStickerFromPicker(page); + await expectTimelineSticker(page, roomId1); }); }); diff --git a/playwright/e2e/widgets/widget-pip-close.spec.ts b/playwright/e2e/widgets/widget-pip-close.spec.ts new file mode 100644 index 0000000000..c8073a3405 --- /dev/null +++ b/playwright/e2e/widgets/widget-pip-close.spec.ts @@ -0,0 +1,169 @@ +/* +Copyright 2022 Mikhail Aheichyk +Copyright 2022 Nordeck IT + Consulting GmbH. +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 { IWidget } from "matrix-widget-api/src/interfaces/IWidget"; +import type { MatrixEvent, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { test, expect } from "../../element-web-test"; +import { Client } from "../../pages/client"; + +const DEMO_WIDGET_ID = "demo-widget-id"; +const DEMO_WIDGET_NAME = "Demo Widget"; +const DEMO_WIDGET_TYPE = "demo"; +const ROOM_NAME = "Demo"; + +const DEMO_WIDGET_HTML = ` + + + Demo Widget + + + + + + +`; + +// mostly copied from src/utils/WidgetUtils.waitForRoomWidget with small modifications +async function waitForRoomWidget(client: Client, widgetId: string, roomId: string, add: boolean): Promise { + await client.evaluate( + (matrixClient, { widgetId, roomId, add }) => { + return new Promise((resolve, reject) => { + function eventsInIntendedState(evList: MatrixEvent[]) { + const widgetPresent = evList.some((ev) => { + return ev.getContent() && ev.getContent()["id"] === widgetId; + }); + if (add) { + return widgetPresent; + } else { + return !widgetPresent; + } + } + + const room = matrixClient.getRoom(roomId); + + const startingWidgetEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); + if (eventsInIntendedState(startingWidgetEvents)) { + resolve(); + return; + } + + function onRoomStateEvents(ev: MatrixEvent) { + if (ev.getRoomId() !== roomId || ev.getType() !== "im.vector.modular.widgets") return; + + const currentWidgetEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); + + if (eventsInIntendedState(currentWidgetEvents)) { + matrixClient.removeListener("RoomState.events" as RoomStateEvent.Events, onRoomStateEvents); + resolve(); + } + } + + matrixClient.on("RoomState.events" as RoomStateEvent.Events, onRoomStateEvents); + }); + }, + { widgetId, roomId, add }, + ); +} + +test.describe("Widget PIP", () => { + test.use({ + displayName: "Mike", + botCreateOpts: { displayName: "Bot", autoAcceptInvites: false }, + }); + + let demoWidgetUrl: string; + test.beforeEach(async ({ webserver }) => { + demoWidgetUrl = webserver.start(DEMO_WIDGET_HTML); + }); + + for (const userRemove of ["leave", "kick", "ban"] as const) { + test(`should be closed on ${userRemove}`, async ({ page, app, bot, user }) => { + const roomId = await app.client.createRoom({ + name: ROOM_NAME, + invite: [bot.credentials.userId], + }); + + // sets bot to Admin and user to Moderator + await app.client.sendStateEvent(roomId, "m.room.power_levels", { + users: { + [user.userId]: 50, + [bot.credentials.userId]: 100, + }, + }); + + // bot joins the room + await bot.joinRoom(roomId); + + // setup widget via state event + const content: IWidget = { + id: DEMO_WIDGET_ID, + creatorUserId: "somebody", + type: DEMO_WIDGET_TYPE, + name: DEMO_WIDGET_NAME, + url: demoWidgetUrl, + }; + await app.client.sendStateEvent(roomId, "im.vector.modular.widgets", content, DEMO_WIDGET_ID); + + // open the room + await app.viewRoomByName(ROOM_NAME); + + // wait for widget state event + await waitForRoomWidget(app.client, DEMO_WIDGET_ID, roomId, true); + + // activate widget in pip mode + await page.evaluate( + ({ widgetId, roomId }) => { + window.mxActiveWidgetStore.setWidgetPersistence(widgetId, roomId, true); + }, + { + widgetId: DEMO_WIDGET_ID, + roomId, + }, + ); + + // checks that pip window is opened + await expect(page.locator(".mx_WidgetPip")).toBeVisible(); + + // checks that widget is opened in pip + const iframe = page.frameLocator(`iframe[title="${DEMO_WIDGET_NAME}"]`); + await expect(iframe.locator("#demo")).toBeVisible(); + + const userId = user.userId; + if (userRemove == "leave") { + await app.client.leave(roomId); + } else if (userRemove == "kick") { + await bot.kick(roomId, userId); + } else if (userRemove == "ban") { + await bot.ban(roomId, userId); + } + + // checks that pip window is closed + await expect(iframe.locator(".mx_WidgetPip")).not.toBeVisible(); + }); + } +}); diff --git a/playwright/global.d.ts b/playwright/global.d.ts index c537d0a142..166bfbe993 100644 --- a/playwright/global.d.ts +++ b/playwright/global.d.ts @@ -25,6 +25,9 @@ declare global { mxSettingsStore: { setValue(settingName: string, roomId: string | null, level: SettingLevel, value: any): Promise; }; + mxActiveWidgetStore: { + setWidgetPersistence(widgetId: string, roomId: string | null, val: boolean): void; + }; matrixcs: typeof Matrix; } } diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts index fcd0d86e02..6c56cb90d3 100644 --- a/playwright/pages/client.ts +++ b/playwright/pages/client.ts @@ -314,6 +314,54 @@ export class Client { }, credentials); } + /** + * Sets account data for the user. + * @param type The type of account data to set + * @param content The content to set + */ + public async setAccountData(type: string, content: IContent): Promise { + const client = await this.prepareClient(); + return client.evaluate( + async (client, { type, content }) => { + await client.setAccountData(type, content); + }, + { type, content }, + ); + } + + /** + * Sends a state event into the room. + * @param roomId ID of the room to send the event into + * @param eventType type of event to send + * @param content the event content to send + * @param stateKey the state key to use + */ + public async sendStateEvent( + roomId: string, + eventType: string, + content: IContent, + stateKey?: string, + ): Promise { + const client = await this.prepareClient(); + return client.evaluate( + async (client, { roomId, eventType, content, stateKey }) => { + return client.sendStateEvent(roomId, eventType, content, stateKey); + }, + { roomId, eventType, content, stateKey }, + ); + } + + /** + * Leaves the given room. + * @param roomId ID of the room to leave + */ + public async leave(roomId: string): Promise { + const client = await this.prepareClient(); + return client.evaluate(async (client, roomId) => { + await client.leave(roomId); + }, roomId); + } + /** * Sets the directory visibility for a room. * @param roomId ID of the room to set the directory visibility for diff --git a/playwright/plugins/webserver/index.ts b/playwright/plugins/webserver/index.ts index 2fe083f179..1bc2cbfa42 100644 --- a/playwright/plugins/webserver/index.ts +++ b/playwright/plugins/webserver/index.ts @@ -33,7 +33,7 @@ export class Webserver { const address = this.server.address() as AddressInfo; console.log(`Started webserver at ${address.address}:${address.port}`); - return `http://localhost:${address.port}/`; + return `http://localhost:${address.port}`; } public stop(): void { diff --git a/playwright/snapshots/widgets/layout.spec.ts/apps-drawer-linux.png b/playwright/snapshots/widgets/layout.spec.ts/apps-drawer-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..20618f5d66e398decfd674f41b3af98c915d81c3 GIT binary patch literal 6617 zcmeHLYe14&x29>vzL`qDrkOlu>NMVJEUhdZuhis?G#$stQi*9)%<)ztB7)Lo(&j6* zu)LyFYL1vtq9WeVsqvB^c>}L#g_5YCq9O`%Hgo=*pXbl{H-BEuTF-ia z|IycT=NEgwfIuKSVP0-OK_FZGA&@QK{7VN&;9*KJ_}PN~$@2#Yt!Lj11Y(d4b31h| zi9A1!dw2e;6U+9bRDQzfVTD!m52 zi?3Ob?{jy5{1x;3zwWBxJA+G4o(aAC)uF3j+!^}CE$6qByZ`mu{qSSm&1|SnyIT%N z-@=R}xcdq>9m`X@ZY6Ry#$@QY656ce>hu$w=ClO@Q9VI19_+uR zv#cWP(@uVV00Oz58wkhnF^T4|0yw@JiM0G z0Te8FKA}>JX9!@6=n(i|Gs<-pRjX6gIE?#qqsZwBY-8#*a^9AsWMG%-dNU)%p8fiPJ=fX|#k*k5zw&EE3H zUz#0=#EBMb5SWyLZp!?)Y(Pf2&opw_T+#g;0{J=nWA2@XwKXfoN1-LjtkJ~QVYlm1 z)2dtBO3U)^8RSGe9T~pnWeQv3NE~@|Z)3bgg`_kp#?!bdEH!5=0Z9tui2Rk6v%a|I z)8H)G-@dX?snO zqvB>DV5TrcL|-TBC(G>=s=P299!o5J5TG0VY}iYh()1=6n(Ve`e9>z}P&mRo$f0O# z4D6!QeTp10*~Q>y?aDQm)-JSQl}*AaM4Dc=K5wlRWz=8j&K@hG#~!bqPLcU`Kdzgz z{VxCer{;me!J-CQ030K6v}xi42%fV!guE7rwaB;v10;*lDCt2zty zm;U1=p3kDP#`~kqEayf^R#M5xH)QJi?fca124Bg#;xa~wKq%#EJk1LueHq-~nKk$K zc0Bd9mdzEp-HZbt+hv|BN{4<0g1G5rnyvvkGGJZy9>Nyb>>50QI!A4IW9(#MuW6XQ zeUN#5SAD(8ImuihaOMbY`M0J)V^3!!S(~c}D zgl`cDM1IqRXdiyAG5c>}NtWv~#ZUwVXHj017^Lew`IwCEVcFnhUt66Q)%5#zpW5cj z74SCNbpojk4Gr-^9Fn&@BkQcG=-0mc1qKGnUfM_%A5F7XX9o`h5am%c?>9M(%GG_x zQYdrB2p9GiCbbLrrO0ooq05~EnCkJz9ywK#{OUjf3^7=donft!y^%)S{}I7LC*a)9 zQe0hN@fx*4{L=kyH37%t9+a}g!b2hTN9Jew;UiXrZIZ$XXDGY=0~9@ZW7~9>C57{} zV^7Qr_02fMedO38CI49X8ROKocZYBUn5<5gBE#pShspb{wmxk7FnP;XT`cN4UAhtq1N*pV!jH^E(b6jpEFBiXxZ}-$VmvfQ@)5iQl5BAuFUw8)_ z%fgeY``>!h8T}X`LlnX!`g^N<`#Og%!}@#>SUR&sjOn}{CVrCPGg6x`T>jN`{rMwV-sOhv3l zL1dq}rK8JnC*m*nT@lUq3yXVtdd#|N1tEn`PNz@XG9t=*uZZ-qV-KR}&2}amL!Q}Y z+9$e=Y2CRx8YgJf1))+#8Bg@1vB7<$tPSj2iEtrq)iXHu{kA##%h_bvOvL$VDc*!I zn4&urM@SsTrJs%89E=)43nw;1g(;#@Tg=iW<%U(9S5%a@tE#00-7(JY;cl05ki;dm zK{fYrs#65p?c6wjq~8t0^M15`yKreR@_4d+^N}jL_*A1|H1S>EFK_`YSb5(gOXyPK zk?}^Y(0o@_Rb`;yUmM2v30dM|JR@;_#H?%JmM&p9H1PR_2mU1*V5B0f@L(1l zTN|ldnsOQ4wxa4w*PC#-s}C(UyV#I$QHx2=(T-Iq8^s-Yzql20l2{+vhF~cpu&8PO zZDO8o{!$tlJxCoi94{BgiO%l!w~$imaQ4rX{+Gd6X-pMsitBR-a@TAQoy_qD-~y}0~pX&}I_JY~GWtgGKIZjDTh zVUAk+L7Sat`Iyq)p{pAU?;67fsbaKLGq1FU500y=#ed8S)Xt$0NYd|(GW?P3_AKuG z7@cq-7aP_Vqj|JD)4?p=s7YgJm-aH$12va-7LTqEv<)jf<)Dz4uUHe;Qalx#s?1xf zJx;nKjT5|K8yJ86b@c$INX$QP;98^HDD*rBmvPO05>!-D#b*Xhq9<@(VDelA^}85N z%qv&=^UZ(-YIrNs{ zX|t|2S&}E8hA8wSuJ=29xaU`1Qc{8)UF_g-%H|6x!U!w*=I$RZ10}3&5 zxN=F#tfgV`@|8=-yf+Hc3cLs_>(5Egfmh5He}g~F*DJfF>v%wmx{tk$mau2dXWUXy ztNHnkE04!80)&A>yuc^##k-JRo_Zw$-Fjo2NS`_J0O5JB^W)JHJTpbLyIee=M=2PU z)td^CP#c-^a*=Wbha8_=WAv;O5`{Q%2zuqjMFxr+wH^(4MZBb;C)+z$EY{$NC}Yf3 zp+m|9*QK=ThtMg>s=!4My&BAZRA!<%iE56@Jrr_2d?_zS^@{l18&R%s^52m1NdsK~ zU#Zv9kP=4+_5B;HD1WR)=9Otrd2YA}OMjzeyD*5Z2xt;YxpXL~=D>zuG1<&vxhQ># zB|a}|K?<3ybR9_Rd1(N$sa_%0)<;Wd{4aGz4O~zo`Hzt;y9eh!axw*DWXVE6yrQHi z(k@$rUbwDOYpsY`EBb><#pm?z{t*l1Kf>?9PF1}MV#rU}mksw0o1B)|>ubi%mE{_R zpTl}yNk4xiC%ijn>ONGN&2>2hfpjhuVLJo-=w75l$jy-A)M{Iu{CeEbpnk3E z&}R8)K$g=1_8P*|u`x(qm7OL^Lt;c-V(O0A*? z5cExm;d{n6+(==sdpE*){hP-R18OwvB|O$-?zAHowN{s5mf^fYc=UBtrE|^mh#;hZ zy6U{7{_Nr^18E6s_c4CNZs^hMfIwPvDv;kwZYZ~-l^wKsgmf&m@erQ3>!K5id3
  • 1O z#Z9!>dh5GBsTFzRx=Wn`tT`N5BjaKr%S?vFa-v%Y0 z^k(GO@lgd_55T|oX0$&uFaW2t_)&nNHu%B^XXdDV2)KKPzWYFH9e>A_sDnnPV17?Y z@0HTBUN3Lk$fy{YX9O1T?G@#ktNS^|aOdQfm`FPW2J9;g(W$8|tu4#1H7cs0CYNj| zW}N(E4j=vm5b31;U+oMdqh>~*H)eR8^JYYT0r>X9GyBI?ar#+yd;MdoVkZH`iZ;@z ziB)~l=dr!(xGP|~QN1>SPYZSif&*QzI?d3_x;k=YKW@iYpl;WG>_%TjptR?G;qE@b zWUJ@znLu4l*59U+%=F1{gfoLONl}%D_44>NF=`UcEsOJ56-` z_WAB5ed3~i+}ax0U~{p-^w+kVpE)eP-raxYc>L>Ko+3-A5=IJJkqqX$;1fOW{nvwC zrXFFdbHf*#lUFKR1eUIgzt1(wTAI5(O;Qpvouu^w=elkiwsWJjAu3JJq?pm;Xa$n! z$j=)jbMItgDj+pWQ#M40jX8#2rt?xKAUH2RhP+DK**zRVDcXNJt?9k1iDs<>{r1$h z<}^QWy6A(Kux>_x*C_jPTOOJzyV<` zM$-!M0-`+Q*ovT73G!re*E@KEgtq)~)EF%jwPU zvG++~OQJ3V``D1_o}$slSm0mQR8DZ8P$+a#AA!+;_s9u-Fc`_x7-!aDgbm4bn9kdW z9lMr)BIrJC0#(uFu*gFMlMbuFs^nZnhi7akRQ-X^qx` z5cFAEgM>VQr1Vucn`Eah~e zyu?I(Ada{(dN&oRO+RHwQ^_2I*K;}DsIHBz7{^ZNY=opLr}B@6ddL(dx-QFaITR=q z4qutsH=&kdwPy}rTX+W}fxo)gqBxz{7I{&i^Ry`2uczQdRQ!hwdkYwdfNZ^#kEfNB z+n{-)v5kkbT-UFh2lNYGc|M%yqTskF#e}GR3dlySh$uFLvgb0K7Oa zzdhZ|R9<4mhghXD$KWySS>b%d1UTmwKv)d{M0~i?>mn#K@PK!r)h3O0fP<==te4{ot{e(Jh#LoW>EC8e0SDHdWtU;?Zz_xtfw zO1T_>&>XhM<`OwLmK9!NUp^GaRv)|k=WqK!>=`&FT#>BRna;f z%C=2fm?UHP7g>`xx5uvx29{S=S_PE*a?Nu?NV9adt9Pz7_xhHt)d@3wA&JA`xQ|cL ze}cocp#;me(4s{7UZ0(ZlTT0edE0RFBZ~7CY>nDb{X)C&cgx~DkmpxvQ86*xS9(x# z7C~e>99dqJEHJtDdKYN=ywQJs9T?U_cED{a#qwJws&4=&Psi53OAEnGJ;F`rM^tm| zM#Cmj6kKhgh)b0hDnT=yQyV2fX<=X&6V#q%LWv`MFjPtc*#(vK7RSm!de2rp=ljg2 zsl)l-U;AZZ(+YgxiOb9Ekp_+nFR&WaWs2!byt>gHbF!mgCi-PDk|BjN8)w1bn6(pznu#<_2ooKjMFU@(&>XiH85jQ~;fo^YXRT+5##WY zpu$Md64>pHJieD6P6iqa-4TKci6b0d&Zs=MsJnN=}MoqvzEcz4zndCn1 z^(x?^C=tuR50ENn>;~9s@)8pt~iV+L{W*$KASf zh9sr>Mg(H1)HCqGgY_E@Kv&@p4A2dE+io2R>Qmi{_|Reo8Wc!Uxbc_&$F#l)Rpyy7 UrzghRz&Z%b-Peuw!_PPW8_KWkJ^%m! literal 0 HcmV?d00001