Create more cypress tests and utilities (#8494)

pull/28788/head^2
Michael Telatynski 2022-05-04 15:11:33 +01:00 committed by GitHub
parent fd6498a821
commit 77a437f30a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 330 additions and 52 deletions

View File

@ -22,10 +22,10 @@ describe("Registration", () => {
let synapse: SynapseInstance;
beforeEach(() => {
cy.visit("/#/register");
cy.startSynapse("consent").then(data => {
synapse = data;
});
cy.visit("/#/register");
});
afterEach(() => {
@ -34,14 +34,16 @@ describe("Registration", () => {
it("registers an account and lands on the home screen", () => {
cy.get(".mx_ServerPicker_change", { timeout: 15000 }).click();
cy.get(".mx_ServerPickerDialog_otherHomeserver").type(`http://localhost:${synapse.port}`);
cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl);
cy.get(".mx_ServerPickerDialog_continue").click();
// wait for the dialog to go away
cy.get('.mx_ServerPickerDialog').should('not.exist');
cy.get("#mx_RegistrationForm_username").type("alice");
cy.get("#mx_RegistrationForm_password").type("totally a great password");
cy.get("#mx_RegistrationForm_passwordConfirm").type("totally a great password");
cy.get(".mx_Login_submit").click();
cy.get(".mx_RegistrationEmailPromptDialog button.mx_Dialog_primary").click();
cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy input").click();
cy.get(".mx_InteractiveAuthEntryComponents_termsSubmit").click();

View File

@ -0,0 +1,57 @@
/*
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";
describe("Login", () => {
let synapse: SynapseInstance;
beforeEach(() => {
cy.visit("/#/login");
cy.startSynapse("consent").then(data => {
synapse = data;
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
describe("m.login.password", () => {
const username = "user1234";
const password = "p4s5W0rD";
beforeEach(() => {
cy.registerUser(synapse, username, password);
});
it("logs in with an existing account and lands on the home screen", () => {
cy.get(".mx_ServerPicker_change", { timeout: 15000 }).click();
cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl);
cy.get(".mx_ServerPickerDialog_continue").click();
// wait for the dialog to go away
cy.get('.mx_ServerPickerDialog').should('not.exist');
cy.get("#mx_LoginForm_username").type(username);
cy.get("#mx_LoginForm_password").type(password);
cy.get(".mx_Login_submit").click();
cy.url().should('contain', '/#/home');
});
});
});

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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
import type { UserCredentials } from "../../support/login";
describe("UserMenu", () => {
let synapse: SynapseInstance;
let user: UserCredentials;
beforeEach(() => {
cy.startSynapse("consent").then(data => {
synapse = data;
cy.initTestUser(synapse, "Jeff").then(credentials => {
user = credentials;
});
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
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);
});
});

View File

@ -21,6 +21,7 @@ import * as os from "os";
import * as crypto from "crypto";
import * as childProcess from "child_process";
import * as fse from "fs-extra";
import * as net from "net";
import PluginEvents = Cypress.PluginEvents;
import PluginConfigOptions = Cypress.PluginConfigOptions;
@ -31,11 +32,13 @@ import PluginConfigOptions = Cypress.PluginConfigOptions;
interface SynapseConfig {
configDir: string;
registrationSecret: string;
// Synapse must be configured with its public_baseurl so we have to allocate a port & url at this stage
baseUrl: string;
port: number;
}
export interface SynapseInstance extends SynapseConfig {
synapseId: string;
port: number;
}
const synapses = new Map<string, SynapseInstance>();
@ -44,6 +47,16 @@ function randB64Bytes(numBytes: number): string {
return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, "");
}
async function getFreePort(): Promise<number> {
return new Promise<number>(resolve => {
const srv = net.createServer();
srv.listen(0, () => {
const port = (<net.AddressInfo>srv.address()).port;
srv.close(() => resolve(port));
});
});
}
async function cfgDirFromTemplate(template: string): Promise<SynapseConfig> {
const templateDir = path.join(__dirname, "templates", template);
@ -64,12 +77,16 @@ async function cfgDirFromTemplate(template: string): Promise<SynapseConfig> {
const macaroonSecret = randB64Bytes(16);
const formSecret = randB64Bytes(16);
// now copy homeserver.yaml, applying sustitutions
const port = await getFreePort();
const baseUrl = `http://localhost:${port}`;
// now copy homeserver.yaml, applying substitutions
console.log(`Gen ${path.join(templateDir, "homeserver.yaml")}`);
let hsYaml = await fse.readFile(path.join(templateDir, "homeserver.yaml"), "utf8");
hsYaml = hsYaml.replace(/{{REGISTRATION_SECRET}}/g, registrationSecret);
hsYaml = hsYaml.replace(/{{MACAROON_SECRET_KEY}}/g, macaroonSecret);
hsYaml = hsYaml.replace(/{{FORM_SECRET}}/g, formSecret);
hsYaml = hsYaml.replace(/{{PUBLIC_BASEURL}}/g, baseUrl);
await fse.writeFile(path.join(tempDir, "homeserver.yaml"), hsYaml);
// now generate a signing key (we could use synapse's config generation for
@ -80,6 +97,8 @@ async function cfgDirFromTemplate(template: string): Promise<SynapseConfig> {
await fse.writeFile(path.join(tempDir, "localhost.signing.key"), `ed25519 x ${signingKey}`);
return {
port,
baseUrl,
configDir: tempDir,
registrationSecret,
};
@ -101,7 +120,7 @@ async function synapseStart(template: string): Promise<SynapseInstance> {
"--name", containerName,
"-d",
"-v", `${synCfg.configDir}:/data`,
"-p", "8008/tcp",
"-p", `${synCfg.port}:8008/tcp`,
"matrixdotorg/synapse:develop",
"run",
], (err, stdout) => {
@ -110,26 +129,27 @@ async function synapseStart(template: string): Promise<SynapseInstance> {
});
});
// Get the port that docker allocated: specifying only one
// port above leaves docker to just grab a free one, although
// in hindsight we need to put the port in public_baseurl in the
// config really, so this will probably need changing to use a fixed
// / configured port.
const port = await new Promise<number>((resolve, reject) => {
childProcess.execFile('docker', [
"port", synapseId, "8008",
synapses.set(synapseId, { synapseId, ...synCfg });
console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`);
// Await Synapse healthcheck
await new Promise<void>((resolve, reject) => {
childProcess.execFile("docker", [
"exec", synapseId,
"curl",
"--connect-timeout", "30",
"--retry", "30",
"--retry-delay", "1",
"--retry-all-errors",
"--silent",
"http://localhost:8008/health",
], { encoding: 'utf8' }, (err, stdout) => {
if (err) reject(err);
resolve(Number(stdout.trim().split(":")[1]));
else resolve();
});
});
synapses.set(synapseId, Object.assign({
port,
synapseId,
}, synCfg));
console.log(`Started synapse with id ${synapseId} on port ${port}.`);
return synapses.get(synapseId);
}

View File

@ -1,6 +1,6 @@
server_name: "localhost"
pid_file: /data/homeserver.pid
public_baseurl: http://localhost:5005/
public_baseurl: "{{PUBLIC_BASEURL}}"
listeners:
- port: 8008
tls: false

View File

@ -17,3 +17,4 @@ limitations under the License.
/// <reference types="cypress" />
import "./synapse";
import "./login";

86
cypress/support/login.ts Normal file
View File

@ -0,0 +1,86 @@
/*
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 Chainable = Cypress.Chainable;
import { SynapseInstance } from "../plugins/synapsedocker";
export interface UserCredentials {
accessToken: string;
userId: string;
deviceId: string;
password: string;
homeServer: string;
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Generates a test user and instantiates an Element session with that user.
* @param synapse the synapse returned by startSynapse
* @param displayName the displayName to give the test user
*/
initTestUser(synapse: SynapseInstance, displayName: string): Chainable<UserCredentials>;
}
}
}
Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: string): Chainable<UserCredentials> => {
const username = Cypress._.uniqueId("userId_");
const password = Cypress._.uniqueId("password_");
return cy.registerUser(synapse, username, password, displayName).then(() => {
const url = `${synapse.baseUrl}/_matrix/client/r0/login`;
return cy.request<{
access_token: string;
user_id: string;
device_id: string;
home_server: string;
}>({
url,
method: "POST",
body: {
"type": "m.login.password",
"identifier": {
"type": "m.id.user",
"user": username,
},
"password": password,
},
});
}).then(response => {
return 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);
win.localStorage.setItem("mx_access_token", response.body.access_token);
win.localStorage.setItem("mx_device_id", response.body.device_id);
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,
}));
});
});
});

View File

@ -16,6 +16,8 @@ limitations under the License.
/// <reference types="cypress" />
import * as crypto from 'crypto';
import Chainable = Cypress.Chainable;
import AUTWindow = Cypress.AUTWindow;
import { SynapseInstance } from "../plugins/synapsedocker";
@ -29,12 +31,27 @@ declare global {
* @param template path to template within cypress/plugins/synapsedocker/template/ directory.
*/
startSynapse(template: string): Chainable<SynapseInstance>;
/**
* Custom command wrapping task:synapseStop whilst preventing uncaught exceptions
* for if Synapse stopping races with the app's background sync loop.
* @param synapse the synapse instance returned by startSynapse
*/
stopSynapse(synapse: SynapseInstance): Chainable<AUTWindow>;
/**
* Register a user on the given Synapse using the shared registration secret.
* @param synapse the synapse instance returned by startSynapse
* @param username the username of the user to register
* @param password the password of the user to register
* @param displayName optional display name to set on the newly registered user
*/
registerUser(
synapse: SynapseInstance,
username: string,
password: string,
displayName?: string,
): Chainable<Credentials>;
}
}
}
@ -51,5 +68,54 @@ function stopSynapse(synapse: SynapseInstance): Chainable<AUTWindow> {
});
}
interface Credentials {
accessToken: string;
userId: string;
deviceId: string;
homeServer: string;
}
function registerUser(
synapse: SynapseInstance,
username: string,
password: string,
displayName?: string,
): Chainable<Credentials> {
const url = `${synapse.baseUrl}/_synapse/admin/v1/register`;
return cy.then(() => {
// get a nonce
return cy.request<{ nonce: string }>({ url });
}).then(response => {
const { nonce } = response.body;
const mac = crypto.createHmac('sha1', synapse.registrationSecret).update(
`${nonce}\0${username}\0${password}\0notadmin`,
).digest('hex');
return cy.request<{
access_token: string;
user_id: string;
home_server: string;
device_id: string;
}>({
url,
method: "POST",
body: {
nonce,
username,
password,
mac,
admin: false,
displayname: displayName,
},
});
}).then(response => ({
homeServer: response.body.home_server,
accessToken: response.body.access_token,
userId: response.body.user_id,
deviceId: response.body.device_id,
}));
}
Cypress.Commands.add("startSynapse", startSynapse);
Cypress.Commands.add("stopSynapse", stopSynapse);
Cypress.Commands.add("registerUser", registerUser);

View File

@ -9,7 +9,7 @@ It aims to cover:
## Running the Tests
Our Cypress tests run automatically as part of our CI along with our other tests,
on every pull request and on every merge to develop.
on every pull request and on every merge to develop & master.
However the Cypress tests are run, an element-web must be running on
http://localhost:8080 (this is configured in `cypress.json`) - this is what will
@ -53,17 +53,17 @@ Synapse can be launched with different configurations in order to test element
in different configurations. `cypress/plugins/synapsedocker/templates` contains
template configuration files for each different configuration.
Each test suite can then launch whatever Syanpse instances it needs it whatever
Each test suite can then launch whatever Synapse instances it needs it whatever
configurations.
Note that although tests should stop the Synapse instances after running and the
plugin also stop any remaining instances after all tests have run, it is possible
to be left with some stray containers if, for example, you terminate a test such
that the `after()` does not run and also exit Cypress uncleanly. All the containers
it starts are prefixed so they are easy to recognise. They can be removed safely.
it starts are prefixed, so they are easy to recognise. They can be removed safely.
After each test run, logs from the Syanpse instances are saved in `cypress/synapselogs`
with each instance in a separate directory named after it's ID. These logs are removed
After each test run, logs from the Synapse instances are saved in `cypress/synapselogs`
with each instance in a separate directory named after its ID. These logs are removed
at the start of each test run.
## Writing Tests
@ -73,23 +73,29 @@ https://docs.cypress.io/guides/references/best-practices .
### Getting a Synapse
The key difference is in starting Synapse instances. Tests use this plugin via
`cy.task()` to provide a Synapse instance to log into:
`cy.startSynapse()` to provide a Synapse instance to log into:
```
cy.task<SynapseInstance>("synapseStart", "consent").then(result => {
synapseId = result.synapseId;
synapsePort = result.port;
```javascript
cy.startSynapse("consent").then(result => {
synapse = result;
});
```
This returns an object with information about the Synapse instance, including what port
it was started on and the ID that needs to be passed to shut it down again. It also
returns the registration shared secret (`registrationSecret`) that can be used to
register users via the REST API.
register users via the REST API. The Synapse has been ensured ready to go by awaiting
its internal health-check.
Synapse instances should be reasonably cheap to start (you may see the first one take a
while as it pulls the Docker image), so it's generally expected that tests will start a
Synapse instance for each test suite, ie. in `before()`, and then tear it down in `after()`.
Synapse instance for each test suite, i.e. in `before()`, and then tear it down in `after()`.
To later destroy your Synapse you should call `stopSynapse`, passing the SynapseInstance
object you received when starting it.
```javascript
cy.stopSynapse(synapse);
```
### Synapse Config Templates
When a Synapse instance is started, it's given a config generated from one of the config
@ -100,6 +106,7 @@ in these templates:
* `REGISTRATION_SECRET`: The secret used to register users via the REST API.
* `MACAROON_SECRET_KEY`: Generated each time for security
* `FORM_SECRET`: Generated each time for security
* `PUBLIC_BASEURL`: The localhost url + port combination the synapse is accessible at
* `localhost.signing.key`: A signing key is auto-generated and saved to this file.
Config templates should not contain a signing key and instead assume that one will exist
in this file.
@ -108,24 +115,18 @@ All other files in the template are copied recursively to `/data/`, so the file
in a template can be referenced in the config as `/data/foo.html`.
### Logging In
This doesn't quite exist yet. Most tests will just want to start with the client in a 'logged in'
state, so we should provide an easy way to start a test with element in this state. The
`registrationSecret` provided when starting a Synapse can be used to create a user (porting
the code from https://github.com/matrix-org/matrix-react-sdk/blob/develop/test/end-to-end-tests/src/rest/creator.ts#L49).
We'd then need to log in as this user. Ways of doing this would be:
There exists a basic utility to start the app with a random user already logged in:
```javascript
cy.initTestUser(synapse, "Jeff");
```
It takes the SynapseInstance you received from `startSynapse` and a display name for your test user.
This custom command will register a random userId using the registrationSecret with a random password
and the given display name. The returned Chainable will contain details about the credentials for if
they are needed for User-Interactive Auth or similar but localStorage will already be seeded with them
and the app loaded (path `/`).
1. Fill in the login form. This isn't ideal as it's effectively testing the login process in each
test, and will just be slower.
1. Mint an access token using https://matrix-org.github.io/synapse/develop/admin_api/user_admin_api.html#login-as-a-user
then inject this into element-web. This would probably be fastest, although also relies on correctly
setting up localstorage
1. Mint a login token, inject the Homeserver URL into localstorage and then load element, passing the login
token as a URL parameter. This is a supported way of logging in to element-web, but there's no API
on Synapse to make such a token currently. It would be fairly easy to add a synapse-specific admin API
to do so. We should write tests for token login (and the rest of SSO) at some point anyway though.
If we make this as a convenience API, it can easily be swapped out later: we could start with option 1
and then switch later.
The internals of how this custom command run may be swapped out later,
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
@ -141,9 +142,9 @@ This section mostly summarises general good Cypress testing practice, and should
already familiar with Cypress.
1. Test a well-isolated unit of functionality. The more specific, the easier it will be to tell what's
wrong when they fail.
wrong when they fail.
1. Don't depend on state from other tests: any given test should be able to run in isolation.
1. Try to avoid driving the UI for anything other than the UI you're trying to test. eg. if you're
1. Try to avoid driving the UI for anything other than the UI you're trying to test. e.g. if you're
testing that the user can send a reaction to a message, it's best to send a message using a REST
API, then react to it using the UI, rather than using the element-web UI to send the message.
1. Avoid explicit waits. `cy.get()` will implicitly wait for the specified element to appear and