Move threads e2e tests over to cypress (#8501)

* Add non-consent (default) Synapse template

* Add consent test

* Add create room test

* Stash work

* Initial threads tests

* fix

* Delete old threads e2e tests, plan new ones

* Fix typed s'more

* Try something else

* specify d.ts

* Fix types once and for all?

* Fix the consent tests

* Iterate threads test harness

* Fix dispatcher types

* Iterate threads test

* fix typing

* Alternative import attempt

* let it break let it break let it break

* Tweak types

* Stash

* delint and update docs

* null-guard scrollIntoView

* Iterate threads test

* Apply suggestions from code review
This commit is contained in:
Michael Telatynski 2022-05-10 18:09:31 +01:00 committed by GitHub
parent 14127c777b
commit ad4d3f9a88
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 810 additions and 288 deletions

View file

@ -1,5 +1,7 @@
{
"baseUrl": "http://localhost:8080",
"videoUploadOnPasses": false,
"projectId": "ppvnzg"
"projectId": "ppvnzg",
"experimentalSessionAndOrigin": true,
"experimentalInteractiveRunEvents": true
}

45
cypress/global.d.ts vendored Normal file
View file

@ -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 };

View file

@ -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.
*/
/// <reference types="cypress" />
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<SinonStub>("@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({});
});
});

View file

@ -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);
});
});
});

View file

@ -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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
import Chainable = Cypress.Chainable;
function openCreateRoomDialog(): Chainable<JQuery<HTMLElement>> {
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);
});
});

View file

@ -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.
*/
/// <reference types="cypress" />
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<string>("@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<string>("@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<string>("@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<string>("@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 :)");
});
});

View file

@ -0,0 +1 @@
A synapse configured with user privacy consent disabled

View file

@ -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

View file

@ -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

63
cypress/support/bot.ts Normal file
View file

@ -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.
*/
/// <reference types="cypress" />
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<MatrixClient>;
}
}
}
Cypress.Commands.add("getBot", (synapse: SynapseInstance, displayName?: string): Chainable<MatrixClient> => {
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;
});
});
});

78
cypress/support/client.ts Normal file
View file

@ -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.
*/
/// <reference types="cypress" />
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<MatrixClient | undefined>;
/**
* 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<string>;
/**
* 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<MatrixClient | undefined> => {
return cy.window().then(win => win.mxMatrixClientPeg.matrixClient);
});
Cypress.Commands.add("createRoom", (options: ICreateRoomOpts): Chainable<string> => {
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<void>(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);
});
});

View file

@ -18,3 +18,6 @@ limitations under the License.
import "./synapse";
import "./login";
import "./client";
import "./settings";
import "./bot";

View file

@ -42,6 +42,15 @@ declare global {
}
Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: string): Chainable<UserCredentials> => {
// 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,
}));
});
});

101
cypress/support/settings.ts Normal file
View file

@ -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.
*/
/// <reference types="cypress" />
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<JQuery<HTMLElement>>;
/**
* 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<JQuery<HTMLElement>>;
/**
* 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<JQuery<HTMLElement>>;
/**
* Close user settings, ideally call this in the context of the dialog.
*/
closeUserSettings(): Chainable<JQuery<HTMLElement>>;
/**
* 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<JQuery<HTMLElement>>;
/**
* 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<JQuery<HTMLElement>>;
}
}
}
Cypress.Commands.add("openUserMenu", (): Chainable<JQuery<HTMLElement>> => {
cy.get('[aria-label="User menu"]').click();
return cy.get(".mx_ContextualMenu");
});
Cypress.Commands.add("openUserSettings", (tab?: string): Chainable<JQuery<HTMLElement>> => {
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<JQuery<HTMLElement>> => {
return cy.get(".mx_TabbedView_tabLabels").within(() => {
cy.get(".mx_TabbedView_tabLabel").contains(tab).click();
});
});
Cypress.Commands.add("closeUserSettings", (): Chainable<JQuery<HTMLElement>> => {
return cy.get('[aria-label="Close dialog"]').click();
});
Cypress.Commands.add("joinBeta", (name: string): Chainable<JQuery<HTMLElement>> => {
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<JQuery<HTMLElement>> => {
return cy.get(".mx_BetaCard_title").contains(name).closest(".mx_BetaCard").within(() => {
return cy.get(".mx_BetaCard_buttons").contains("Leave the beta").click();
});
});

View file

@ -60,7 +60,8 @@ function startSynapse(template: string): Chainable<SynapseInstance> {
return cy.task<SynapseInstance>("synapseStart", template);
}
function stopSynapse(synapse: SynapseInstance): Chainable<AUTWindow> {
function stopSynapse(synapse?: SynapseInstance): Chainable<AUTWindow> {
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';

View file

@ -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.

View file

@ -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",

View file

@ -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<void>;
mxLoginWithAccessToken: (hsUrl: string, accessToken: string) => Promise<void>;
mxAutoRageshakeStore?: AutoRageshakeStore;
mxDispatcher: MatrixDispatcher;
}
interface Electron {

View file

@ -665,7 +665,7 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise<void
}
if (credentials.pickleKey) {
let encryptedAccessToken;
let encryptedAccessToken: IEncryptedPayload;
try {
// try to encrypt the access token using the pickle key
const encrKey = await pickleKeyToAesKey(credentials.pickleKey);

View file

@ -1197,6 +1197,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
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);

View file

@ -67,9 +67,8 @@ export class MatrixDispatcher extends Dispatcher<ActionPayload> {
export const defaultDispatcher = new MatrixDispatcher();
const anyGlobal = <any>global;
if (!anyGlobal.mxDispatcher) {
anyGlobal.mxDispatcher = defaultDispatcher;
if (!window.mxDispatcher) {
window.mxDispatcher = defaultDispatcher;
}
export default defaultDispatcher;

View file

@ -407,7 +407,7 @@ export class RoomViewStore extends Store<ActionPayload> {
dis.dispatch({
action: Action.JoinRoomError,
roomId,
err: err,
err,
});
}
}

View file

@ -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<void> {
if (!indexedDB) {
@ -206,8 +205,8 @@ async function idbInit(): Promise<void> {
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(); };
});
}

View file

@ -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<ElementSession>,
restCreator: RestSessionCreator): Promise<void> {
@ -51,12 +49,6 @@ export async function scenario(createSession: (s: string) => Promise<ElementSess
const alice = await createUser("alice");
const bob = await createUser("bob");
// Enable threads for Alice & Bob before going any further as it requires refreshing the app
// which otherwise loses all performance ticks.
console.log("Enabling threads: ");
await enableThreads(alice);
await enableThreads(bob);
await toastScenarios(alice, bob);
await userViewScenarios(alice, bob);
await roomDirectoryScenarios(alice, bob);
@ -64,7 +56,6 @@ export async function scenario(createSession: (s: string) => Promise<ElementSess
console.log("create REST users:");
const charlies = await createRestUsers(restCreator);
await lazyLoadingScenarios(alice, bob, charlies);
await threadsScenarios(alice, bob);
// do spaces scenarios last as the rest of the alice/bob tests may get confused by spaces
await spacesScenarios(alice, bob);

View file

@ -1,83 +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 { ElementSession } from "../session";
import {
assertTimelineThreadSummary,
clickTimelineThreadSummary,
editThreadMessage,
reactThreadMessage,
redactThreadMessage,
sendThreadMessage,
startThread,
} from "../usecases/threads";
import { sendMessage } from "../usecases/send-message";
import {
assertThreadListHasUnreadIndicator,
clickLatestThreadInThreadListPanel,
closeRoomRightPanel,
openThreadListPanel,
} from "../usecases/rightpanel";
export async function threadsScenarios(alice: ElementSession, bob: ElementSession): Promise<void> {
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 :)");
}

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();
}

View file

@ -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"