diff --git a/cypress.json b/cypress.json index 2c39bb411f..d41cc70dd0 100644 --- a/cypress.json +++ b/cypress.json @@ -1,5 +1,7 @@ { "baseUrl": "http://localhost:8080", "videoUploadOnPasses": false, - "projectId": "ppvnzg" + "projectId": "ppvnzg", + "experimentalSessionAndOrigin": true, + "experimentalInteractiveRunEvents": true } diff --git a/cypress/global.d.ts b/cypress/global.d.ts new file mode 100644 index 0000000000..d0fb732778 --- /dev/null +++ b/cypress/global.d.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 "matrix-js-sdk/src/@types/global"; +import type { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client"; +import type { RoomMemberEvent } from "matrix-js-sdk/src/models/room-member"; +import type { MatrixDispatcher } from "../src/dispatcher/dispatcher"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface ApplicationWindow { + mxMatrixClientPeg: { + matrixClient?: MatrixClient; + }; + mxDispatcher: MatrixDispatcher; + beforeReload?: boolean; // for detecting reloads + // Partial type for the matrix-js-sdk module, exported by browser-matrix + matrixcs: { + MatrixClient: typeof MatrixClient; + ClientEvent: typeof ClientEvent; + RoomMemberEvent: typeof RoomMemberEvent; + }; + } + } + + interface Window { + mxDispatcher: MatrixDispatcher; // to appease the MatrixDispatcher import + } +} + +export { MatrixClient }; diff --git a/cypress/integration/2-login/consent.spec.ts b/cypress/integration/2-login/consent.spec.ts new file mode 100644 index 0000000000..a4cd31bd26 --- /dev/null +++ b/cypress/integration/2-login/consent.spec.ts @@ -0,0 +1,73 @@ +/* +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 { SinonStub } from "cypress/types/sinon"; + +import { SynapseInstance } from "../../plugins/synapsedocker"; + +describe("Consent", () => { + let synapse: SynapseInstance; + + beforeEach(() => { + cy.startSynapse("consent").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Bob"); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("should prompt the user to consent to terms when server deems it necessary", () => { + // Attempt to create a room using the js-sdk which should return an error with `M_CONSENT_NOT_GIVEN` + cy.window().then(win => { + win.mxMatrixClientPeg.matrixClient.createRoom({}).catch(() => {}); + + // Stub `window.open` - clicking the primary button below will call it + cy.stub(win, "open").as("windowOpen").returns({}); + }); + + // Accept terms & conditions + cy.get(".mx_QuestionDialog").within(() => { + cy.get("#mx_BaseDialog_title").contains("Terms and Conditions"); + cy.get(".mx_Dialog_primary").click(); + }); + + cy.get("@windowOpen").then(stub => { + const url = stub.getCall(0).args[0]; + + // Go to Synapse's consent page and accept it + cy.origin(synapse.baseUrl, { args: { url } }, ({ url }) => { + cy.visit(url); + + cy.get('[type="submit"]').click(); + cy.get("p").contains("Danke schon"); + }); + }); + + // go back to the app + cy.visit("/"); + // wait for the app to re-load + cy.get(".mx_MatrixChat", { timeout: 15000 }); + + // attempt to perform the same action again and expect it to not fail + cy.createRoom({}); + }); +}); diff --git a/cypress/integration/3-user-menu/user-menu.spec.ts b/cypress/integration/3-user-menu/user-menu.spec.ts index b3c482d9f1..671fd4eacf 100644 --- a/cypress/integration/3-user-menu/user-menu.spec.ts +++ b/cypress/integration/3-user-menu/user-menu.spec.ts @@ -19,12 +19,12 @@ limitations under the License. import { SynapseInstance } from "../../plugins/synapsedocker"; import type { UserCredentials } from "../../support/login"; -describe("UserMenu", () => { +describe("User Menu", () => { let synapse: SynapseInstance; let user: UserCredentials; beforeEach(() => { - cy.startSynapse("consent").then(data => { + cy.startSynapse("default").then(data => { synapse = data; cy.initTestUser(synapse, "Jeff").then(credentials => { @@ -38,8 +38,10 @@ describe("UserMenu", () => { }); it("should contain our name & userId", () => { - cy.get('[aria-label="User menu"]', { timeout: 15000 }).click(); - cy.get(".mx_UserMenu_contextMenu_displayName").should("contain", "Jeff"); - cy.get(".mx_UserMenu_contextMenu_userId").should("contain", user.userId); + cy.get('[aria-label="User menu"]').click(); + cy.get(".mx_ContextualMenu").within(() => { + cy.get(".mx_UserMenu_contextMenu_displayName").should("contain", "Jeff"); + cy.get(".mx_UserMenu_contextMenu_userId").should("contain", user.userId); + }); }); }); diff --git a/cypress/integration/4-create-room/create-room.spec.ts b/cypress/integration/4-create-room/create-room.spec.ts new file mode 100644 index 0000000000..d6abab814d --- /dev/null +++ b/cypress/integration/4-create-room/create-room.spec.ts @@ -0,0 +1,64 @@ +/* +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"; +import Chainable = Cypress.Chainable; + +function openCreateRoomDialog(): Chainable> { + cy.get('[aria-label="Add room"]').click(); + cy.get('.mx_ContextualMenu [aria-label="New room"]').click(); + return cy.get(".mx_CreateRoomDialog"); +} + +describe("Create Room", () => { + let synapse: SynapseInstance; + + beforeEach(() => { + cy.startSynapse("default").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Jim"); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("should allow us to create a public room with name, topic & address set", () => { + const name = "Test room 1"; + const topic = "This room is dedicated to this test and this test only!"; + + openCreateRoomDialog().within(() => { + // Fill name & topic + cy.get('[label="Name"]').type(name); + cy.get('[label="Topic (optional)"]').type(topic); + // Change room to public + cy.get('[aria-label="Room visibility"]').click(); + cy.get("#mx_JoinRuleDropdown__public").click(); + // Fill room address + cy.get('[label="Room address"]').type("test-room-1"); + // Submit + cy.get(".mx_Dialog_primary").click(); + }); + + cy.url().should("contain", "/#/room/#test-room-1:localhost"); + cy.get(".mx_RoomHeader_nametext").contains(name); + cy.get(".mx_RoomHeader_topic").contains(topic); + }); +}); diff --git a/cypress/integration/5-threads/threads.spec.ts b/cypress/integration/5-threads/threads.spec.ts new file mode 100644 index 0000000000..43b0058bb1 --- /dev/null +++ b/cypress/integration/5-threads/threads.spec.ts @@ -0,0 +1,214 @@ +/* +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"; +import { MatrixClient } from "../../global"; + +function markWindowBeforeReload(): void { + // mark our window object to "know" when it gets reloaded + cy.window().then(w => w.beforeReload = true); +} + +describe("Threads", () => { + let synapse: SynapseInstance; + + beforeEach(() => { + cy.window().then(win => { + win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests + win.localStorage.setItem("mx_labs_feature_feature_thread", "true"); // Default threads to ON for this spec + }); + cy.startSynapse("default").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Tom"); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("should reload when enabling threads beta", () => { + markWindowBeforeReload(); + + // Turn off + cy.openUserSettings("Labs").within(() => { + // initially the new property is there + cy.window().should("have.prop", "beforeReload", true); + + cy.leaveBeta("Threads"); + // after reload the property should be gone + cy.window().should("not.have.prop", "beforeReload"); + }); + + cy.get(".mx_MatrixChat", { timeout: 15000 }); // wait for the app + markWindowBeforeReload(); + + // Turn on + cy.openUserSettings("Labs").within(() => { + // initially the new property is there + cy.window().should("have.prop", "beforeReload", true); + + cy.joinBeta("Threads"); + // after reload the property should be gone + cy.window().should("not.have.prop", "beforeReload"); + }); + }); + + it("should be usable for a conversation", () => { + let bot: MatrixClient; + cy.getBot(synapse, "BotBob").then(_bot => { + bot = _bot; + }); + + let roomId: string; + cy.createRoom({}).then(_roomId => { + roomId = _roomId; + cy.inviteUser(roomId, bot.getUserId()); + cy.visit("/#/room/" + roomId); + }); + + // User sends message + cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); + + // Wait for message to send, get its ID and save as @threadId + cy.get(".mx_RoomView_body .mx_EventTile").contains("Hello Mr. Bot") + .closest(".mx_EventTile[data-scroll-tokens]").invoke("attr", "data-scroll-tokens").as("threadId"); + + // Bot starts thread + cy.get("@threadId").then(threadId => { + bot.sendMessage(roomId, threadId, { + body: "Hello there", + msgtype: "m.text", + }); + }); + + // User asserts timeline thread summary visible & clicks it + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Hello there"); + cy.get(".mx_RoomView_body .mx_ThreadSummary").click(); + + // User responds in thread + cy.get(".mx_ThreadView .mx_BasicMessageComposer_input").type("Test{enter}"); + + // User asserts summary was updated correctly + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "Tom"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Test"); + + // User reacts to message instead + cy.get(".mx_ThreadView .mx_EventTile").contains("Hello there").closest(".mx_EventTile_line") + .find('[aria-label="React"]').click({ force: true }); // Cypress has no ability to hover + cy.get(".mx_EmojiPicker").within(() => { + cy.get('input[type="text"]').type("wave"); + cy.get('[role="menuitem"]').contains("👋").click(); + }); + + // User redacts their prior response + cy.get(".mx_ThreadView .mx_EventTile").contains("Test").closest(".mx_EventTile_line") + .find('[aria-label="Options"]').click({ force: true }); // Cypress has no ability to hover + cy.get(".mx_IconizedContextMenu").within(() => { + cy.get('[role="menuitem"]').contains("Remove").click(); + }); + cy.get(".mx_TextInputDialog").within(() => { + cy.get(".mx_Dialog_primary").contains("Remove").click(); + }); + + // User asserts summary was updated correctly + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Hello there"); + + // User closes right panel after clicking back to thread list + cy.get(".mx_ThreadView .mx_BaseCard_back").click(); + cy.get(".mx_ThreadPanel .mx_BaseCard_close").click(); + + // Bot responds to thread + cy.get("@threadId").then(threadId => { + bot.sendMessage(roomId, threadId, { + body: "How are things?", + msgtype: "m.text", + }); + }); + + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "How are things?"); + // User asserts thread list unread indicator + cy.get('.mx_HeaderButtons [aria-label="Threads"]').should("have.class", "mx_RightPanel_headerButton_unread"); + + // User opens thread list + cy.get('.mx_HeaderButtons [aria-label="Threads"]').click(); + + // User asserts thread with correct root & latest events & unread dot + cy.get(".mx_ThreadPanel .mx_EventTile_last").within(() => { + cy.get(".mx_EventTile_body").should("contain", "Hello Mr. Bot"); + cy.get(".mx_ThreadSummary_content").should("contain", "How are things?"); + // User opens thread via threads list + cy.get(".mx_EventTile_line").click(); + }); + + // User responds & asserts + cy.get(".mx_ThreadView .mx_BasicMessageComposer_input").type("Great!{enter}"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "Tom"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Great!"); + + // User edits & asserts + cy.get(".mx_ThreadView .mx_EventTile_last").contains("Great!").closest(".mx_EventTile_line").within(() => { + cy.get('[aria-label="Edit"]').click({ force: true }); // Cypress has no ability to hover + cy.get(".mx_BasicMessageComposer_input").type(" How about yourself?{enter}"); + }); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "Tom"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content") + .should("contain", "Great! How about yourself?"); + + // User closes right panel + cy.get(".mx_ThreadView .mx_BaseCard_close").click(); + + // Bot responds to thread and saves the id of their message to @eventId + cy.get("@threadId").then(threadId => { + cy.wrap(bot.sendMessage(roomId, threadId, { + body: "I'm very good thanks", + msgtype: "m.text", + }).then(res => res.event_id)).as("eventId"); + }); + + // User asserts + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content") + .should("contain", "I'm very good thanks"); + + // Bot edits their latest event + cy.get("@eventId").then(eventId => { + bot.sendMessage(roomId, { + "body": "* I'm very good thanks :)", + "msgtype": "m.text", + "m.new_content": { + "body": "I'm very good thanks :)", + "msgtype": "m.text", + }, + "m.relates_to": { + "rel_type": "m.replace", + "event_id": eventId, + }, + }); + }); + + // User asserts + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); + cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content") + .should("contain", "I'm very good thanks :)"); + }); +}); diff --git a/cypress/plugins/synapsedocker/templates/default/README.md b/cypress/plugins/synapsedocker/templates/default/README.md new file mode 100644 index 0000000000..8f6b11f999 --- /dev/null +++ b/cypress/plugins/synapsedocker/templates/default/README.md @@ -0,0 +1 @@ +A synapse configured with user privacy consent disabled diff --git a/cypress/plugins/synapsedocker/templates/default/homeserver.yaml b/cypress/plugins/synapsedocker/templates/default/homeserver.yaml new file mode 100644 index 0000000000..7839c69c46 --- /dev/null +++ b/cypress/plugins/synapsedocker/templates/default/homeserver.yaml @@ -0,0 +1,52 @@ +server_name: "localhost" +pid_file: /data/homeserver.pid +public_baseurl: "{{PUBLIC_BASEURL}}" +listeners: + - port: 8008 + tls: false + bind_addresses: ['::'] + type: http + x_forwarded: true + + resources: + - names: [client] + compress: false + +database: + name: "sqlite3" + args: + database: ":memory:" + +log_config: "/data/log.config" + +rc_messages_per_second: 10000 +rc_message_burst_count: 10000 +rc_registration: + per_second: 10000 + burst_count: 10000 + +rc_login: + address: + per_second: 10000 + burst_count: 10000 + account: + per_second: 10000 + burst_count: 10000 + failed_attempts: + per_second: 10000 + burst_count: 10000 + +media_store_path: "/data/media_store" +uploads_path: "/data/uploads" +enable_registration: true +enable_registration_without_verification: true +disable_msisdn_registration: false +registration_shared_secret: "{{REGISTRATION_SECRET}}" +report_stats: false +macaroon_secret_key: "{{MACAROON_SECRET_KEY}}" +form_secret: "{{FORM_SECRET}}" +signing_key_path: "/data/localhost.signing.key" + +trusted_key_servers: + - server_name: "matrix.org" +suppress_key_server_warning: true diff --git a/cypress/plugins/synapsedocker/templates/default/log.config b/cypress/plugins/synapsedocker/templates/default/log.config new file mode 100644 index 0000000000..ac232762da --- /dev/null +++ b/cypress/plugins/synapsedocker/templates/default/log.config @@ -0,0 +1,50 @@ +# Log configuration for Synapse. +# +# This is a YAML file containing a standard Python logging configuration +# dictionary. See [1] for details on the valid settings. +# +# Synapse also supports structured logging for machine readable logs which can +# be ingested by ELK stacks. See [2] for details. +# +# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema +# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html + +version: 1 + +formatters: + precise: + format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' + +handlers: + # A handler that writes logs to stderr. Unused by default, but can be used + # instead of "buffer" and "file" in the logger handlers. + console: + class: logging.StreamHandler + formatter: precise + +loggers: + synapse.storage.SQL: + # beware: increasing this to DEBUG will make synapse log sensitive + # information such as access tokens. + level: INFO + + twisted: + # We send the twisted logging directly to the file handler, + # to work around https://github.com/matrix-org/synapse/issues/3471 + # when using "buffer" logger. Use "console" to log to stderr instead. + handlers: [console] + propagate: false + +root: + level: INFO + + # Write logs to the `buffer` handler, which will buffer them together in memory, + # then write them to a file. + # + # Replace "buffer" with "console" to log to stderr instead. (Note that you'll + # also need to update the configuration for the `twisted` logger above, in + # this case.) + # + handlers: [console] + +disable_existing_loggers: false diff --git a/cypress/support/bot.ts b/cypress/support/bot.ts new file mode 100644 index 0000000000..3ba7b89cde --- /dev/null +++ b/cypress/support/bot.ts @@ -0,0 +1,63 @@ +/* +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 request from "browser-request"; + +import type { MatrixClient } from "matrix-js-sdk/src/client"; +import { SynapseInstance } from "../plugins/synapsedocker"; +import Chainable = Cypress.Chainable; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Returns a new Bot instance + * @param synapse the instance on which to register the bot user + * @param displayName the display name to give to the bot user + */ + getBot(synapse: SynapseInstance, displayName?: string): Chainable; + } + } +} + +Cypress.Commands.add("getBot", (synapse: SynapseInstance, displayName?: string): Chainable => { + const username = Cypress._.uniqueId("userId_"); + const password = Cypress._.uniqueId("password_"); + return cy.registerUser(synapse, username, password, displayName).then(credentials => { + return cy.window().then(win => { + const cli = new win.matrixcs.MatrixClient({ + baseUrl: synapse.baseUrl, + userId: credentials.userId, + deviceId: credentials.deviceId, + accessToken: credentials.accessToken, + request, + }); + + cli.on(win.matrixcs.RoomMemberEvent.Membership, (event, member) => { + if (member.membership === "invite" && member.userId === cli.getUserId()) { + cli.joinRoom(member.roomId); + } + }); + + cli.startClient(); + + return cli; + }); + }); +}); diff --git a/cypress/support/client.ts b/cypress/support/client.ts new file mode 100644 index 0000000000..eef3aa1086 --- /dev/null +++ b/cypress/support/client.ts @@ -0,0 +1,78 @@ +/* +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 type { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests"; +import type { MatrixClient } from "matrix-js-sdk/src/client"; +import type { Room } from "matrix-js-sdk/src/models/room"; +import Chainable = Cypress.Chainable; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Returns the MatrixClient from the MatrixClientPeg + */ + getClient(): Chainable; + /** + * Create a room with given options. + * @param options the options to apply when creating the room + * @return the ID of the newly created room + */ + createRoom(options: ICreateRoomOpts): Chainable; + /** + * Invites the given user to the given room. + * @param roomId the id of the room to invite to + * @param userId the id of the user to invite + */ + inviteUser(roomId: string, userId: string): Chainable<{}>; + } + } +} + +Cypress.Commands.add("getClient", (): Chainable => { + return cy.window().then(win => win.mxMatrixClientPeg.matrixClient); +}); + +Cypress.Commands.add("createRoom", (options: ICreateRoomOpts): Chainable => { + return cy.window().then(async win => { + const cli = win.mxMatrixClientPeg.matrixClient; + const resp = await cli.createRoom(options); + const roomId = resp.room_id; + + if (!cli.getRoom(roomId)) { + await new Promise(resolve => { + const onRoom = (room: Room) => { + if (room.roomId === roomId) { + cli.off(win.matrixcs.ClientEvent.Room, onRoom); + resolve(); + } + }; + cli.on(win.matrixcs.ClientEvent.Room, onRoom); + }); + } + + return roomId; + }); +}); + +Cypress.Commands.add("inviteUser", (roomId: string, userId: string): Chainable<{}> => { + return cy.getClient().then(async (cli: MatrixClient) => { + return cli.invite(roomId, userId); + }); +}); diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 598cc4de7e..6535d3a0b1 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -18,3 +18,6 @@ limitations under the License. import "./synapse"; import "./login"; +import "./client"; +import "./settings"; +import "./bot"; diff --git a/cypress/support/login.ts b/cypress/support/login.ts index 2d7d3ef84a..90e5c5dbdc 100644 --- a/cypress/support/login.ts +++ b/cypress/support/login.ts @@ -42,6 +42,15 @@ declare global { } Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: string): Chainable => { + // XXX: work around Cypress not clearing IDB between tests + cy.window().then(win => { + win.indexedDB.databases().then(databases => { + databases.forEach(database => { + win.indexedDB.deleteDatabase(database.name); + }); + }); + }); + const username = Cypress._.uniqueId("userId_"); const password = Cypress._.uniqueId("password_"); return cy.registerUser(synapse, username, password, displayName).then(() => { @@ -64,7 +73,7 @@ Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: str }, }); }).then(response => { - return cy.window().then(win => { + cy.window().then(win => { // Seed the localStorage with the required credentials win.localStorage.setItem("mx_hs_url", synapse.baseUrl); win.localStorage.setItem("mx_user_id", response.body.user_id); @@ -73,14 +82,17 @@ Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: str win.localStorage.setItem("mx_is_guest", "false"); win.localStorage.setItem("mx_has_pickle_key", "false"); win.localStorage.setItem("mx_has_access_token", "true"); - - return cy.visit("/").then(() => ({ - password, - accessToken: response.body.access_token, - userId: response.body.user_id, - deviceId: response.body.device_id, - homeServer: response.body.home_server, - })); }); + + return cy.visit("/").then(() => { + // wait for the app to load + return cy.get(".mx_MatrixChat", { timeout: 15000 }); + }).then(() => ({ + password, + accessToken: response.body.access_token, + userId: response.body.user_id, + deviceId: response.body.device_id, + homeServer: response.body.home_server, + })); }); }); diff --git a/cypress/support/settings.ts b/cypress/support/settings.ts new file mode 100644 index 0000000000..11f48c2db2 --- /dev/null +++ b/cypress/support/settings.ts @@ -0,0 +1,101 @@ +/* +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 "./client"; // XXX: without an (any) import here, types break down +import Chainable = Cypress.Chainable; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Open the top left user menu, returning a handle to the resulting context menu. + */ + openUserMenu(): Chainable>; + + /** + * Open user settings (via user menu), returning a handle to the resulting dialog. + * @param tab the name of the tab to switch to after opening, optional. + */ + openUserSettings(tab?: string): Chainable>; + + /** + * Switch settings tab to the one by the given name, ideally call this in the context of the dialog. + * @param tab the name of the tab to switch to. + */ + switchTabUserSettings(tab: string): Chainable>; + + /** + * Close user settings, ideally call this in the context of the dialog. + */ + closeUserSettings(): Chainable>; + + /** + * Join the given beta, the `Labs` tab must already be opened, + * ideally call this in the context of the dialog. + * @param name the name of the beta to join. + */ + joinBeta(name: string): Chainable>; + + /** + * Leave the given beta, the `Labs` tab must already be opened, + * ideally call this in the context of the dialog. + * @param name the name of the beta to leave. + */ + leaveBeta(name: string): Chainable>; + } + } +} + +Cypress.Commands.add("openUserMenu", (): Chainable> => { + cy.get('[aria-label="User menu"]').click(); + return cy.get(".mx_ContextualMenu"); +}); + +Cypress.Commands.add("openUserSettings", (tab?: string): Chainable> => { + cy.openUserMenu().within(() => { + cy.get('[aria-label="All settings"]').click(); + }); + return cy.get(".mx_UserSettingsDialog").within(() => { + if (tab) { + cy.switchTabUserSettings(tab); + } + }); +}); + +Cypress.Commands.add("switchTabUserSettings", (tab: string): Chainable> => { + return cy.get(".mx_TabbedView_tabLabels").within(() => { + cy.get(".mx_TabbedView_tabLabel").contains(tab).click(); + }); +}); + +Cypress.Commands.add("closeUserSettings", (): Chainable> => { + return cy.get('[aria-label="Close dialog"]').click(); +}); + +Cypress.Commands.add("joinBeta", (name: string): Chainable> => { + return cy.get(".mx_BetaCard_title").contains(name).closest(".mx_BetaCard").within(() => { + return cy.get(".mx_BetaCard_buttons").contains("Join the beta").click(); + }); +}); + +Cypress.Commands.add("leaveBeta", (name: string): Chainable> => { + return cy.get(".mx_BetaCard_title").contains(name).closest(".mx_BetaCard").within(() => { + return cy.get(".mx_BetaCard_buttons").contains("Leave the beta").click(); + }); +}); diff --git a/cypress/support/synapse.ts b/cypress/support/synapse.ts index 1571ddef36..aa1ba085f5 100644 --- a/cypress/support/synapse.ts +++ b/cypress/support/synapse.ts @@ -60,7 +60,8 @@ function startSynapse(template: string): Chainable { return cy.task("synapseStart", template); } -function stopSynapse(synapse: SynapseInstance): Chainable { +function stopSynapse(synapse?: SynapseInstance): Chainable { + if (!synapse) return; // Navigate away from app to stop the background network requests which will race with Synapse shutting down return cy.window().then((win) => { win.location.href = 'about:blank'; diff --git a/docs/cypress.md b/docs/cypress.md index 95b9b330d1..d0a06bb163 100644 --- a/docs/cypress.md +++ b/docs/cypress.md @@ -31,7 +31,7 @@ This will run the Cypress tests once, non-interactively. You can also run individual tests this way too, as you'd expect: ``` -yarn run test:cypress cypress/integration/1-register/register.spec.ts +yarn run test:cypress --spec cypress/integration/1-register/register.spec.ts ``` Cypress also has its own UI that you can use to run and debug the tests. @@ -131,12 +131,17 @@ but the signature can be maintained for simpler maintenance. ### Joining a Room Many tests will also want to start with the client in a room, ready to send & receive messages. Best way to do this may be to get an access token for the user and use this to create a room with the REST -API before logging the user in. +API before logging the user in. You can make use of `cy.getBot(synapse)` and `cy.getClient()` to do this. ### Convenience APIs We should probably end up with convenience APIs that wrap the synapse creation, logging in and room creation that can be called to set up tests. +### Using matrix-js-sdk +Due to the way we run the Cypress tests in CI, at this time you can only use the matrix-js-sdk module +exposed on `window.matrixcs`. This has the limitation that it is only accessible with the app loaded. +This may be revisited in the future. + ## Good Test Hygiene This section mostly summarises general good Cypress testing practice, and should not be news to anyone already familiar with Cypress. diff --git a/package.json b/package.json index 7c9dac0b31..698780ad1c 100644 --- a/package.json +++ b/package.json @@ -166,7 +166,7 @@ "babel-jest": "^26.6.3", "blob-polyfill": "^6.0.20211015", "chokidar": "^3.5.1", - "cypress": "^9.5.4", + "cypress": "^9.6.1", "enzyme": "^3.11.0", "enzyme-to-json": "^3.6.2", "eslint": "8.9.0", diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index d0f266470c..4d87e0a2f0 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -51,6 +51,7 @@ import { ConsoleLogger, IndexedDBLogStore } from "../rageshake/rageshake"; import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import AutoRageshakeStore from "../stores/AutoRageshakeStore"; import { IConfigOptions } from "../IConfigOptions"; +import { MatrixDispatcher } from "../dispatcher/dispatcher"; /* eslint-disable @typescript-eslint/naming-convention */ @@ -109,6 +110,7 @@ declare global { mxSendSentryReport: (userText: string, issueUrl: string, error: Error) => Promise; mxLoginWithAccessToken: (hsUrl: string, accessToken: string) => Promise; mxAutoRageshakeStore?: AutoRageshakeStore; + mxDispatcher: MatrixDispatcher; } interface Electron { diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 516e18ddc7..64cdf445d9 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -665,7 +665,7 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise { private scrollIntoView(eventId?: string, pixelOffset?: number, offsetBase?: number): void { const doScroll = () => { + if (!this.messagePanel.current) return; if (eventId) { debuglog("TimelinePanel scrolling to eventId " + eventId + " at position " + (offsetBase * 100) + "% + " + pixelOffset); diff --git a/src/dispatcher/dispatcher.ts b/src/dispatcher/dispatcher.ts index 15e2f8f9bc..4d4f83d4de 100644 --- a/src/dispatcher/dispatcher.ts +++ b/src/dispatcher/dispatcher.ts @@ -67,9 +67,8 @@ export class MatrixDispatcher extends Dispatcher { export const defaultDispatcher = new MatrixDispatcher(); -const anyGlobal = global; -if (!anyGlobal.mxDispatcher) { - anyGlobal.mxDispatcher = defaultDispatcher; +if (!window.mxDispatcher) { + window.mxDispatcher = defaultDispatcher; } export default defaultDispatcher; diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 29e974498d..0cfe684441 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -407,7 +407,7 @@ export class RoomViewStore extends Store { dis.dispatch({ action: Action.JoinRoomError, roomId, - err: err, + err, }); } } diff --git a/src/utils/StorageManager.ts b/src/utils/StorageManager.ts index 7d9ce885f7..ed37064920 100644 --- a/src/utils/StorageManager.ts +++ b/src/utils/StorageManager.ts @@ -18,6 +18,7 @@ import { LocalStorageCryptoStore } from 'matrix-js-sdk/src/crypto/store/localSto import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb"; import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store"; import { logger } from "matrix-js-sdk/src/logger"; +import { MatrixClient } from 'matrix-js-sdk/src/client'; import Analytics from '../Analytics'; @@ -25,7 +26,7 @@ const localStorage = window.localStorage; // just *accessing* indexedDB throws an exception in firefox with // indexeddb disabled. -let indexedDB; +let indexedDB: IDBFactory; try { indexedDB = window.indexedDB; } catch (e) {} @@ -161,7 +162,7 @@ async function checkCryptoStore() { track("Crypto store using IndexedDB inaccessible"); } try { - exists = await LocalStorageCryptoStore.exists(localStorage); + exists = LocalStorageCryptoStore.exists(localStorage); log(`Crypto store using local storage contains data? ${exists}`); return { exists, healthy: true }; } catch (e) { @@ -172,12 +173,10 @@ async function checkCryptoStore() { return { exists, healthy: false }; } -export function trackStores(client) { - if (client.store && client.store.on) { - client.store.on("degraded", () => { - track("Sync store using IndexedDB degraded to memory"); - }); - } +export function trackStores(client: MatrixClient) { + client.store?.on?.("degraded", () => { + track("Sync store using IndexedDB degraded to memory"); + }); } /** @@ -188,16 +187,16 @@ export function trackStores(client) { * and if it is true and not crypto data is found, an error is * presented to the user. * - * @param {bool} cryptoInited True if crypto has been set up + * @param {boolean} cryptoInited True if crypto has been set up */ -export function setCryptoInitialised(cryptoInited) { - localStorage.setItem("mx_crypto_initialised", cryptoInited); +export function setCryptoInitialised(cryptoInited: boolean) { + localStorage.setItem("mx_crypto_initialised", String(cryptoInited)); } /* Simple wrapper functions around IndexedDB. */ -let idb = null; +let idb: IDBDatabase = null; async function idbInit(): Promise { if (!indexedDB) { @@ -206,8 +205,8 @@ async function idbInit(): Promise { idb = await new Promise((resolve, reject) => { const request = indexedDB.open("matrix-react-sdk", 1); request.onerror = reject; - request.onsuccess = (event) => { resolve(request.result); }; - request.onupgradeneeded = (event) => { + request.onsuccess = () => { resolve(request.result); }; + request.onupgradeneeded = () => { const db = request.result; db.createObjectStore("pickleKey"); db.createObjectStore("account"); @@ -266,6 +265,6 @@ export async function idbDelete( const objectStore = txn.objectStore(table); const request = objectStore.delete(key); request.onerror = reject; - request.onsuccess = (event) => { resolve(); }; + request.onsuccess = () => { resolve(); }; }); } diff --git a/test/end-to-end-tests/src/scenario.ts b/test/end-to-end-tests/src/scenario.ts index 4ce762f36f..dc6e1309d7 100644 --- a/test/end-to-end-tests/src/scenario.ts +++ b/test/end-to-end-tests/src/scenario.ts @@ -30,8 +30,6 @@ import { stickerScenarios } from './scenarios/sticker'; import { userViewScenarios } from "./scenarios/user-view"; import { ssoCustomisationScenarios } from "./scenarios/sso-customisations"; import { updateScenarios } from "./scenarios/update"; -import { threadsScenarios } from "./scenarios/threads"; -import { enableThreads } from "./usecases/threads"; export async function scenario(createSession: (s: string) => Promise, restCreator: RestSessionCreator): Promise { @@ -51,12 +49,6 @@ export async function scenario(createSession: (s: string) => Promise Promise { - console.log(" threads tests:"); - - // Alice sends message - await sendMessage(alice, "Hey bob, what do you think about X?"); - - // Bob responds via a thread - await startThread(bob, "I think its Y!"); - - // Alice sees thread summary and opens thread panel - await assertTimelineThreadSummary(alice, "bob", "I think its Y!"); - await assertTimelineThreadSummary(bob, "bob", "I think its Y!"); - await clickTimelineThreadSummary(alice); - - // Bob closes right panel - await closeRoomRightPanel(bob); - - // Alice responds in thread - await sendThreadMessage(alice, "Great!"); - await assertTimelineThreadSummary(alice, "alice", "Great!"); - await assertTimelineThreadSummary(bob, "alice", "Great!"); - - // Alice reacts to Bob's message instead - await reactThreadMessage(alice, "😁"); - await assertTimelineThreadSummary(alice, "alice", "Great!"); - await assertTimelineThreadSummary(bob, "alice", "Great!"); - await redactThreadMessage(alice); - await assertTimelineThreadSummary(alice, "bob", "I think its Y!"); - await assertTimelineThreadSummary(bob, "bob", "I think its Y!"); - - // Bob sees notification dot on the thread header icon - await assertThreadListHasUnreadIndicator(bob); - - // Bob opens thread list and inspects it - await openThreadListPanel(bob); - - // Bob opens thread in right panel via thread list - await clickLatestThreadInThreadListPanel(bob); - - // Bob responds to thread - await sendThreadMessage(bob, "Testing threads s'more :)"); - await assertTimelineThreadSummary(alice, "bob", "Testing threads s'more :)"); - await assertTimelineThreadSummary(bob, "bob", "Testing threads s'more :)"); - - // Bob edits thread response - await editThreadMessage(bob, "Testing threads some more :)"); - await assertTimelineThreadSummary(alice, "bob", "Testing threads some more :)"); - await assertTimelineThreadSummary(bob, "bob", "Testing threads some more :)"); -} diff --git a/test/end-to-end-tests/src/usecases/threads.ts b/test/end-to-end-tests/src/usecases/threads.ts deleted file mode 100644 index d263f147df..0000000000 --- a/test/end-to-end-tests/src/usecases/threads.ts +++ /dev/null @@ -1,153 +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 { strict as assert } from "assert"; - -import { ElementSession } from "../session"; - -export async function enableThreads(session: ElementSession): Promise { - session.log.step(`enables threads`); - await session.page.evaluate(() => { - window["mxSettingsStore"].setValue("feature_thread", null, "device", true); - }); - session.log.done(); -} - -async function clickReplyInThread(session: ElementSession): Promise { - const events = await session.queryAll(".mx_EventTile_line"); - const event = events[events.length - 1]; - await event.hover(); - const button = await event.$(".mx_MessageActionBar_threadButton"); - await button.click(); -} - -export async function sendThreadMessage(session: ElementSession, message: string): Promise { - session.log.step(`sends thread response "${message}"`); - const composer = await session.query(".mx_ThreadView .mx_BasicMessageComposer_input"); - await composer.click(); - await composer.type(message); - - const text = await session.innerText(composer); - assert.equal(text.trim(), message.trim()); - await composer.press("Enter"); - // wait for the message to appear sent - await session.query(".mx_ThreadView .mx_EventTile_last:not(.mx_EventTile_sending)"); - session.log.done(); -} - -export async function editThreadMessage(session: ElementSession, message: string): Promise { - session.log.step(`edits thread response "${message}"`); - const events = await session.queryAll(".mx_EventTile_line"); - const event = events[events.length - 1]; - await event.hover(); - const button = await event.$(".mx_MessageActionBar_editButton"); - await button.click(); - - const composer = await session.query(".mx_ThreadView .mx_EditMessageComposer .mx_BasicMessageComposer_input"); - await composer.click({ clickCount: 3 }); - await composer.type(message); - - const text = await session.innerText(composer); - assert.equal(text.trim(), message.trim()); - await composer.press("Enter"); - // wait for the edit to appear sent - await session.query(".mx_ThreadView .mx_EventTile_last:not(.mx_EventTile_sending)"); - session.log.done(); -} - -export async function redactThreadMessage(session: ElementSession): Promise { - session.log.startGroup(`redacts latest thread response`); - - const events = await session.queryAll(".mx_ThreadView .mx_EventTile_line"); - const event = events[events.length - 1]; - await event.hover(); - - session.log.step(`clicks the ... button`); - let button = await event.$('.mx_MessageActionBar [aria-label="Options"]'); - await button.click(); - session.log.done(); - - session.log.step(`clicks the remove option`); - button = await session.query('.mx_IconizedContextMenu_item[aria-label="Remove"]'); - await button.click(); - session.log.done(); - - session.log.step(`confirms in the dialog`); - button = await session.query(".mx_Dialog_primary"); - await button.click(); - session.log.done(); - - await session.query(".mx_ThreadView .mx_RedactedBody"); - await session.delay(1000); // give the app a chance to settle - - session.log.endGroup(); -} - -export async function reactThreadMessage(session: ElementSession, reaction: string): Promise { - session.log.startGroup(`reacts to latest thread response`); - - const events = await session.queryAll(".mx_ThreadView .mx_EventTile_line"); - const event = events[events.length - 1]; - await event.hover(); - - session.log.step(`clicks the reaction button`); - let button = await event.$('.mx_MessageActionBar [aria-label="React"]'); - await button.click(); - session.log.done(); - - session.log.step(`selects reaction`); - button = await session.query(`.mx_EmojiPicker_item_wrapper[aria-label=${reaction}]`); - await button.click; - session.log.done(); - - session.log.step(`clicks away`); - button = await session.query(".mx_ContextualMenu_background"); - await button.click(); - session.log.done(); - - session.log.endGroup(); -} - -export async function startThread(session: ElementSession, response: string): Promise { - session.log.startGroup(`creates thread on latest message`); - - await clickReplyInThread(session); - await sendThreadMessage(session, response); - - session.log.endGroup(); -} - -export async function assertTimelineThreadSummary( - session: ElementSession, - sender: string, - content: string, -): Promise { - session.log.step("asserts the timeline thread summary is as expected"); - const summaries = await session.queryAll(".mx_MainSplit_timeline .mx_ThreadSummary"); - const summary = summaries[summaries.length - 1]; - assert.equal(await session.innerText(await summary.$(".mx_ThreadSummary_sender")), sender); - assert.equal(await session.innerText(await summary.$(".mx_ThreadSummary_content")), content); - session.log.done(); -} - -export async function clickTimelineThreadSummary(session: ElementSession): Promise { - session.log.step(`clicks the latest thread summary in the timeline`); - - const summaries = await session.queryAll(".mx_MainSplit_timeline .mx_ThreadSummary"); - await summaries[summaries.length - 1].click(); - - session.log.done(); -} diff --git a/yarn.lock b/yarn.lock index c1f274ecf4..1a7053bba9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3352,10 +3352,10 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.11.tgz#d66700c5eacfac1940deb4e3ee5642792d85cd33" integrity sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw== -cypress@^9.5.4: - version "9.6.0" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-9.6.0.tgz#84473b3362255fa8f5e627a596e58575c9e5320f" - integrity sha512-nNwt9eBQmSENamwa8LxvggXksfyzpyYaQ7lNBLgks3XZ6dPE/6BCQFBzeAyAPt/bNXfH3tKPkAyhiAZPYkWoEg== +cypress@^9.6.1: + version "9.6.1" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-9.6.1.tgz#a7d6b5a53325b3dc4960181f5800a5ade0f085eb" + integrity sha512-ECzmV7pJSkk+NuAhEw6C3D+RIRATkSb2VAHXDY6qGZbca/F9mv5pPsj2LO6Ty6oIFVBTrwCyL9agl28MtJMe2g== dependencies: "@cypress/request" "^2.88.10" "@cypress/xvfb" "^1.2.4"