diff --git a/cypress.json b/cypress.json index 009b1f38800..92f7d0f2540 100644 --- a/cypress.json +++ b/cypress.json @@ -7,5 +7,6 @@ "retries": { "runMode": 2, "openMode": 0 - } + }, + "chromeWebSecurity": false } diff --git a/cypress/integration/9-widgets/stickers.spec.ts b/cypress/integration/9-widgets/stickers.spec.ts new file mode 100644 index 00000000000..0d0fee8776f --- /dev/null +++ b/cypress/integration/9-widgets/stickers.spec.ts @@ -0,0 +1,163 @@ +/* +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 { SynapseInstance } from "../../plugins/synapsedocker"; + +const STICKER_PICKER_WIDGET_ID = "fake-sticker-picker"; +const STICKER_PICKER_WIDGET_NAME = "Fake Stickers"; +const STICKER_NAME = "Test Sticker"; +const ROOM_NAME_1 = "Sticker Test"; +const ROOM_NAME_2 = "Sticker Test Two"; +const STICKER_MESSAGE = JSON.stringify({ + action: "m.sticker", + api: "fromWidget", + data: { + name: "teststicker", + description: STICKER_NAME, + file: "test.png", + content: { + body: STICKER_NAME, + msgtype: "m.sticker", + url: "mxc://somewhere", + }, + }, + requestId: "1", + widgetId: STICKER_PICKER_WIDGET_ID, +}); +const WIDGET_HTML = ` + + + Fake Sticker Picker + + + + + + + +`; + +function openStickerPicker() { + cy.get('.mx_MessageComposer_buttonMenu').click(); + cy.get('#stickersButton').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(); + }); + + // Sticker picker should close itself after sending. + cy.get(".mx_AppTileFullWidth#stickers").should('not.exist'); +} + +function expectTimelineSticker(roomId: string) { + // Make sure it's in the right room + cy.get('.mx_EventTile_sticker > a') + .should("have.attr", "href") + .and("include", `/${roomId}/`); + + // Make sure the image points at the sticker image + cy.get(`img[alt="${STICKER_NAME}"]`) + .should("have.attr", "src") + .and("match", /thumbnail\/somewhere\?/); +} + +describe("Stickers", () => { + // 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 synapse: SynapseInstance; + + beforeEach(() => { + cy.startSynapse("default").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Sally"); + }); + cy.serveHtmlFile(WIDGET_HTML).then(url => { + stickerPickerUrl = url; + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + cy.stopWebServers(); + }); + + 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", { + [STICKER_PICKER_WIDGET_ID]: { + content: { + type: "m.stickerpicker", + name: STICKER_PICKER_WIDGET_NAME, + url: stickerPickerUrl, + }, + 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); + }); + }); +}); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index eab5441c203..bc62efb03f9 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -20,6 +20,7 @@ import PluginEvents = Cypress.PluginEvents; import PluginConfigOptions = Cypress.PluginConfigOptions; import { performance } from "./performance"; import { synapseDocker } from "./synapsedocker"; +import { webserver } from "./webserver"; /** * @type {Cypress.PluginConfig} @@ -27,4 +28,5 @@ import { synapseDocker } from "./synapsedocker"; export default function(on: PluginEvents, config: PluginConfigOptions) { performance(on, config); synapseDocker(on, config); + webserver(on, config); } diff --git a/cypress/plugins/webserver.ts b/cypress/plugins/webserver.ts new file mode 100644 index 00000000000..55a25a313e3 --- /dev/null +++ b/cypress/plugins/webserver.ts @@ -0,0 +1,52 @@ +/* +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 * as http from "http"; +import { AddressInfo } from "net"; + +import PluginEvents = Cypress.PluginEvents; +import PluginConfigOptions = Cypress.PluginConfigOptions; + +const servers: http.Server[] = []; + +function serveHtmlFile(html: string): string { + const server = http.createServer((req, res) => { + res.writeHead(200, { + "Content-Type": "text/html", + }); + res.end(html); + }); + server.listen(); + servers.push(server); + + return `http://localhost:${(server.address() as AddressInfo).port}/`; +} + +function stopWebServers(): null { + for (const server of servers) { + server.close(); + } + servers.splice(0, servers.length); // clear + + return null; // tell cypress we did the task successfully (doesn't allow undefined) +} + +export function webserver(on: PluginEvents, config: PluginConfigOptions) { + on("task", { serveHtmlFile, stopWebServers }); + on("after:run", stopWebServers); +} diff --git a/cypress/support/client.ts b/cypress/support/client.ts index 6a6a3932711..db27f4d2b1e 100644 --- a/cypress/support/client.ts +++ b/cypress/support/client.ts @@ -47,6 +47,12 @@ declare global { * @param userId the id of the user to invite */ inviteUser(roomId: string, userId: string): Chainable<{}>; + /** + * Sets account data for the user. + * @param type The type of account data. + * @param data The data to store. + */ + setAccountData(type: string, data: object): Chainable<{}>; } } } @@ -91,3 +97,9 @@ Cypress.Commands.add("inviteUser", (roomId: string, userId: string): Chainable<{ return cli.invite(roomId, userId); }); }); + +Cypress.Commands.add("setAccountData", (type: string, data: object): Chainable<{}> => { + return cy.getClient().then(async (cli: MatrixClient) => { + return cli.setAccountData(type, data); + }); +}); diff --git a/cypress/support/iframes.ts b/cypress/support/iframes.ts new file mode 100644 index 00000000000..27bd5e0b8ee --- /dev/null +++ b/cypress/support/iframes.ts @@ -0,0 +1,45 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import Chainable = Cypress.Chainable; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Gets you into the `body` of the selectable iframe. Best to call + * `within({}, () => { ... })` on the returned Chainable to access + * further elements. + * @param selector The jquery selector to find the frame with. + */ + accessIframe(selector: string): Chainable>; + } + } +} + +// Inspired by https://www.cypress.io/blog/2020/02/12/working-with-iframes-in-cypress/ +Cypress.Commands.add("accessIframe", (selector: string): Chainable> => { + return cy.get(selector) + .its("0.contentDocument.body").should("not.be.empty") + // Cypress loses types in the mess of wrapping, so force cast + .then(cy.wrap) as Chainable>; +}); + +// Needed to make this file a module +export { }; diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 6e3b7d8b9cc..b82b950e994 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -29,3 +29,6 @@ import "./clipboard"; import "./util"; import "./app"; import "./percy"; +import "./webserver"; +import "./views"; +import "./iframes"; diff --git a/cypress/support/views.ts b/cypress/support/views.ts new file mode 100644 index 00000000000..c7f55b4ac9c --- /dev/null +++ b/cypress/support/views.ts @@ -0,0 +1,40 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import Chainable = Cypress.Chainable; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Opens the given room by name. The room must be visible in the + * room list. + * @param name The room name to find and click on/open. + */ + viewRoomByName(name: string): Chainable>; + } + } +} + +Cypress.Commands.add("viewRoomByName", (name: string): Chainable> => { + return cy.get(`.mx_RoomTile[aria-label="${name}"]`).click(); +}); + +// Needed to make this file a module +export { }; diff --git a/cypress/support/webserver.ts b/cypress/support/webserver.ts new file mode 100644 index 00000000000..a587e1aa8bf --- /dev/null +++ b/cypress/support/webserver.ts @@ -0,0 +1,52 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import Chainable = Cypress.Chainable; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Starts a web server which serves the given HTML. + * @param html The HTML to serve + * @returns The URL at which the HTML can be accessed. + */ + serveHtmlFile(html: string): Chainable; + + /** + * Stops all running web servers. + */ + stopWebServers(): Chainable; + } + } +} + +function serveHtmlFile(html: string): Chainable { + return cy.task("serveHtmlFile", html); +} + +function stopWebServers(): Chainable { + return cy.task("stopWebServers"); +} + +Cypress.Commands.add("serveHtmlFile", serveHtmlFile); +Cypress.Commands.add("stopWebServers", stopWebServers); + +// Needed to make this file a module +export { }; diff --git a/test/end-to-end-tests/src/scenario.ts b/test/end-to-end-tests/src/scenario.ts index fffb2042531..31855f29e8b 100644 --- a/test/end-to-end-tests/src/scenario.ts +++ b/test/end-to-end-tests/src/scenario.ts @@ -25,7 +25,6 @@ import { ElementSession } from "./session"; import { RestSessionCreator } from "./rest/creator"; import { RestMultiSession } from "./rest/multi"; import { RestSession } from "./rest/session"; -import { stickerScenarios } from './scenarios/sticker'; export async function scenario(createSession: (s: string) => Promise, restCreator: RestSessionCreator): Promise { @@ -51,15 +50,6 @@ export async function scenario(createSession: (s: string) => Promise { diff --git a/test/end-to-end-tests/src/scenarios/sticker.ts b/test/end-to-end-tests/src/scenarios/sticker.ts deleted file mode 100644 index 554eb2785f1..00000000000 --- a/test/end-to-end-tests/src/scenarios/sticker.ts +++ /dev/null @@ -1,143 +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 * as http from "http"; -import { AddressInfo } from "net"; - -import { RestSessionCreator } from "../rest/creator"; -import { ElementSession } from "../session"; -import { login } from "../usecases/login"; -import { selectRoom } from "../usecases/select-room"; -import { sendSticker } from "../usecases/send-sticker"; - -const STICKER_PICKER_WIDGET_ID = "fake-sticker-picker"; -const ROOM_NAME_1 = "Sticker Test"; -const ROOM_NAME_2 = "Sticker Test Two"; -const STICKER_MESSAGE = JSON.stringify({ - action: "m.sticker", - api: "fromWidget", - data: { - name: "teststicker", - description: "Test Sticker", - file: "test.png", - content: { - body: "Test Sticker", - msgtype: "m.sticker", - url: "mxc://somewhere", - }, - }, - requestId: "1", - widgetId: STICKER_PICKER_WIDGET_ID, -}); -const WIDGET_HTML = ` - - - Fake Sticker Picker - - - - - - - -`; - -class WidgetServer { - private server: http.Server = null; - - start() { - this.server = http.createServer(this.onRequest); - this.server.listen(); - } - - stop() { - this.server.close(); - } - - get port(): number { - return (this.server.address()as AddressInfo).port; - } - - onRequest = (req: http.IncomingMessage, res: http.ServerResponse) => { - res.writeHead(200); - res.end(WIDGET_HTML); - }; -} - -export async function stickerScenarios( - username: string, password: string, - session: ElementSession, restCreator: RestSessionCreator, -): Promise { - console.log(" making account to test stickers"); - - const creds = await restCreator.createSession(username, password); - - // we make the room here which also approves the consent stuff - // (besides, we test creating rooms elsewhere: no need to do so again) - await creds.createRoom(ROOM_NAME_1, {}); - await creds.createRoom(ROOM_NAME_2, {}); - - console.log(" injecting fake sticker picker"); - - const widgetServer = new WidgetServer(); - widgetServer.start(); - - const stickerPickerUrl = `http://localhost:${widgetServer.port}/`; - - await creds.put(`/user/${encodeURIComponent(creds.userId())}/account_data/m.widgets`, { - "fake_sticker_picker": { - content: { - type: "m.stickerpicker", - name: "Fake Stickers", - url: stickerPickerUrl, - }, - id: STICKER_PICKER_WIDGET_ID, - }, - }); - - await login(session, username, password, session.hsUrl); - - session.log.startGroup(`can send a sticker`); - await selectRoom(session, ROOM_NAME_1); - await sendSticker(session); - session.log.endGroup(); - - // switch to another room & send another one - session.log.startGroup(`can send a sticker to another room`); - - const navPromise = session.page.waitForNavigation(); - await selectRoom(session, ROOM_NAME_2); - await navPromise; - - await sendSticker(session); - session.log.endGroup(); - - widgetServer.stop(); -}