Merge pull request #16 from matrix-org/bwindels/lltests

Test timeline messages have correct display name with lazy loading
pull/21833/head
Bruno Windels 2018-09-14 14:49:32 +02:00 committed by GitHub
commit 5e8a3db985
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 597 additions and 69 deletions

View File

@ -9,7 +9,11 @@
"author": "",
"license": "ISC",
"dependencies": {
"cheerio": "^1.0.0-rc.2",
"commander": "^2.17.1",
"puppeteer": "^1.6.0"
"puppeteer": "^1.6.0",
"request": "^2.88.0",
"request-promise-native": "^1.0.5",
"uuid": "^3.3.2"
}
}

30
src/rest/consent.js Normal file
View File

@ -0,0 +1,30 @@
/*
Copyright 2018 New Vector Ltd
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.
*/
const request = require('request-promise-native');
const cheerio = require('cheerio');
const url = require("url");
module.exports.approveConsent = async function(consentUrl) {
const body = await request.get(consentUrl);
const doc = cheerio.load(body);
const v = doc("input[name=v]").val();
const u = doc("input[name=u]").val();
const h = doc("input[name=h]").val();
const formAction = doc("form").attr("action");
const absAction = url.resolve(consentUrl, formAction);
await request.post(absAction).form({v, u, h});
};

84
src/rest/creator.js Normal file
View File

@ -0,0 +1,84 @@
/*
Copyright 2018 New Vector Ltd
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.
*/
const util = require('util');
const exec = util.promisify(require('child_process').exec);
const request = require('request-promise-native');
const RestSession = require('./session');
const RestMultiSession = require('./multi');
module.exports = class RestSessionCreator {
constructor(synapseSubdir, hsUrl, cwd) {
this.synapseSubdir = synapseSubdir;
this.hsUrl = hsUrl;
this.cwd = cwd;
}
async createSessionRange(usernames, password) {
const sessionPromises = usernames.map((username) => this.createSession(username, password));
const sessions = await Promise.all(sessionPromises);
return new RestMultiSession(sessions);
}
async createSession(username, password) {
await this._register(username, password);
const authResult = await this._authenticate(username, password);
return new RestSession(authResult);
}
_register(username, password) {
const registerArgs = [
'-c homeserver.yaml',
`-u ${username}`,
`-p ${password}`,
// '--regular-user',
'-a', //until PR gets merged
this.hsUrl
];
const registerCmd = `./scripts/register_new_matrix_user ${registerArgs.join(' ')}`;
const allCmds = [
`cd ${this.synapseSubdir}`,
"source env/bin/activate",
registerCmd
].join(';');
return exec(allCmds, {cwd: this.cwd, encoding: 'utf-8'}).catch((result) => {
const lines = result.stdout.trim().split('\n');
const failureReason = lines[lines.length - 1];
throw new Error(`creating user ${username} failed: ${failureReason}`);
});
}
async _authenticate(username, password) {
const requestBody = {
"type": "m.login.password",
"identifier": {
"type": "m.id.user",
"user": username
},
"password": password
};
const url = `${this.hsUrl}/_matrix/client/r0/login`;
const responseBody = await request.post({url, json: true, body: requestBody});
return {
accessToken: responseBody.access_token,
homeServer: responseBody.home_server,
userId: responseBody.user_id,
deviceId: responseBody.device_id,
hsUrl: this.hsUrl,
};
}
}

61
src/rest/multi.js Normal file
View File

@ -0,0 +1,61 @@
/*
Copyright 2018 New Vector Ltd
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.
*/
const request = require('request-promise-native');
const RestRoom = require('./room');
const {approveConsent} = require('./consent');
module.exports = class RestMultiSession {
constructor(sessions) {
this.sessions = sessions;
}
slice(start, end) {
return new RestMultiSession(this.sessions.slice(start, end));
}
pop(userName) {
const idx = this.sessions.findIndex((s) => s.userName() === userName);
if(idx === -1) {
throw new Error(`user ${userName} not found`);
}
const session = this.sessions.splice(idx, 1)[0];
return session;
}
async setDisplayName(fn) {
await Promise.all(this.sessions.map((s) => s.setDisplayName(fn(s))));
}
async join(roomId) {
const rooms = await Promise.all(this.sessions.map((s) => s.join(roomId)));
return new RestMultiRoom(rooms);
}
}
class RestMultiRoom {
constructor(rooms) {
this.rooms = rooms;
}
async talk(message) {
await Promise.all(this.rooms.map((r) => r.talk(message)));
}
async leave() {
await Promise.all(this.rooms.map((r) => r.leave()));
}
}

42
src/rest/room.js Normal file
View File

@ -0,0 +1,42 @@
/*
Copyright 2018 New Vector Ltd
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.
*/
const uuidv4 = require('uuid/v4');
/* no pun intented */
module.exports = class RestRoom {
constructor(session, roomId) {
this.session = session;
this._roomId = roomId;
}
async talk(message) {
const txId = uuidv4();
await this.session._put(`/rooms/${this._roomId}/send/m.room.message/${txId}`, {
"msgtype": "m.text",
"body": message
});
return txId;
}
async leave() {
await this.session._post(`/rooms/${this._roomId}/leave`);
}
roomId() {
return this._roomId;
}
}

107
src/rest/session.js Normal file
View File

@ -0,0 +1,107 @@
/*
Copyright 2018 New Vector Ltd
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.
*/
const request = require('request-promise-native');
const RestRoom = require('./room');
const {approveConsent} = require('./consent');
module.exports = class RestSession {
constructor(credentials) {
this._credentials = credentials;
this._displayName = null;
}
userId() {
return this._credentials.userId;
}
userName() {
return this._credentials.userId.split(":")[0].substr(1);
}
displayName() {
return this._displayName;
}
async setDisplayName(displayName) {
this._displayName = displayName;
await this._put(`/profile/${this._credentials.userId}/displayname`, {
displayname: displayName
});
}
async join(roomIdOrAlias) {
const {room_id} = await this._post(`/join/${encodeURIComponent(roomIdOrAlias)}`);
return new RestRoom(this, room_id);
}
async createRoom(name, options) {
const body = {
name,
};
if (options.invite) {
body.invite = options.invite;
}
if (options.public) {
body.visibility = "public";
} else {
body.visibility = "private";
}
if (options.dm) {
body.is_direct = true;
}
if (options.topic) {
body.topic = options.topic;
}
const {room_id} = await this._post(`/createRoom`, body);
return new RestRoom(this, room_id);
}
_post(csApiPath, body) {
return this._request("POST", csApiPath, body);
}
_put(csApiPath, body) {
return this._request("PUT", csApiPath, body);
}
async _request(method, csApiPath, body) {
try {
const responseBody = await request({
url: `${this._credentials.hsUrl}/_matrix/client/r0${csApiPath}`,
method,
headers: {
"Authorization": `Bearer ${this._credentials.accessToken}`
},
json: true,
body
});
return responseBody;
} catch(err) {
const responseBody = err.response.body;
if (responseBody.errcode === 'M_CONSENT_NOT_GIVEN') {
await approveConsent(responseBody.consent_uri);
return this._request(method, csApiPath, body);
} else if(responseBody && responseBody.error) {
throw new Error(`${method} ${csApiPath}: ${responseBody.error}`);
} else {
throw new Error(`${method} ${csApiPath}: ${err.response.statusCode}`);
}
}
}
}

View File

@ -21,26 +21,47 @@ const join = require('./tests/join');
const sendMessage = require('./tests/send-message');
const acceptInvite = require('./tests/accept-invite');
const invite = require('./tests/invite');
const receiveMessage = require('./tests/receive-message');
const {
receiveMessage,
checkTimelineContains,
scrollToTimelineTop
} = require('./tests/timeline');
const createRoom = require('./tests/create-room');
const changeRoomSettings = require('./tests/room-settings');
const acceptServerNoticesInviteAndConsent = require('./tests/server-notices-consent');
const getE2EDeviceFromSettings = require('./tests/e2e-device');
const {enableLazyLoading, getE2EDeviceFromSettings} = require('./tests/settings');
const verifyDeviceForUser = require("./tests/verify-device");
module.exports = async function scenario(createSession) {
module.exports = async function scenario(createSession, restCreator) {
async function createUser(username) {
const session = await createSession(username);
await signup(session, session.username, 'testtest');
await signup(session, session.username, 'testtest', session.hsUrl);
await acceptServerNoticesInviteAndConsent(session);
return session;
}
const alice = await createUser("alice");
const bob = await createUser("bob");
const charlies = await createRestUsers(restCreator);
await createDirectoryRoomAndTalk(alice, bob);
await createE2ERoomAndTalk(alice, bob);
await aLazyLoadingTest(alice, bob, charlies);
}
function range(start, amount, step = 1) {
const r = [];
for (let i = 0; i < amount; ++i) {
r.push(start + (i * step));
}
return r;
}
async function createRestUsers(restCreator) {
const usernames = range(1, 10).map((i) => `charly-${i}`);
const charlies = await restCreator.createSessionRange(usernames, 'testtest');
await charlies.setDisplayName((s) => `Charly #${s.userName().split('-')[1]}`);
return charlies;
}
async function createDirectoryRoomAndTalk(alice, bob) {
@ -83,3 +104,38 @@ async function createE2ERoomAndTalk(alice, bob) {
await sendMessage(bob, bobMessage);
await receiveMessage(alice, {sender: "bob", body: bobMessage, encrypted: true});
}
async function aLazyLoadingTest(alice, bob, charlies) {
console.log(" creating a room for lazy loading member scenarios:");
await enableLazyLoading(alice);
const room = "Lazy Loading Test";
const alias = "#lltest:localhost";
const charlyMsg1 = "hi bob!";
const charlyMsg2 = "how's it going??";
await createRoom(bob, room);
await changeRoomSettings(bob, {directory: true, visibility: "public_no_guests", alias});
// wait for alias to be set by server after clicking "save"
// so the charlies can join it.
await bob.delay(500);
const charlyMembers = await charlies.join(alias);
await charlyMembers.talk(charlyMsg1);
await charlyMembers.talk(charlyMsg2);
bob.log.step("sends 20 messages").mute();
for(let i = 20; i >= 1; --i) {
await sendMessage(bob, `I will only say this ${i} time(s)!`);
}
bob.log.unmute().done();
await join(alice, alias);
await scrollToTimelineTop(alice);
//alice should see 2 messages from every charly with
//the correct display name
const expectedMessages = [charlyMsg1, charlyMsg2].reduce((messages, msgText) => {
return charlies.sessions.reduce((messages, charly) => {
return messages.concat({
sender: charly.displayName(),
body: msgText,
});
}, messages);
}, []);
await checkTimelineContains(alice, expectedMessages, "Charly #1-10");
}

View File

@ -35,32 +35,54 @@ class Logger {
constructor(username) {
this.indent = 0;
this.username = username;
this.muted = false;
}
startGroup(description) {
const indent = " ".repeat(this.indent * 2);
console.log(`${indent} * ${this.username} ${description}:`);
if (!this.muted) {
const indent = " ".repeat(this.indent * 2);
console.log(`${indent} * ${this.username} ${description}:`);
}
this.indent += 1;
return this;
}
endGroup() {
this.indent -= 1;
return this;
}
step(description) {
const indent = " ".repeat(this.indent * 2);
process.stdout.write(`${indent} * ${this.username} ${description} ... `);
if (!this.muted) {
const indent = " ".repeat(this.indent * 2);
process.stdout.write(`${indent} * ${this.username} ${description} ... `);
}
return this;
}
done(status = "done") {
process.stdout.write(status + "\n");
if (!this.muted) {
process.stdout.write(status + "\n");
}
return this;
}
mute() {
this.muted = true;
return this;
}
unmute() {
this.muted = false;
return this;
}
}
module.exports = class RiotSession {
constructor(browser, page, username, riotserver) {
constructor(browser, page, username, riotserver, hsUrl) {
this.browser = browser;
this.page = page;
this.hsUrl = hsUrl;
this.riotserver = riotserver;
this.username = username;
this.consoleLog = new LogBuffer(page, "console", (msg) => `${msg.text()}\n`);
@ -72,14 +94,14 @@ module.exports = class RiotSession {
this.log = new Logger(this.username);
}
static async create(username, puppeteerOptions, riotserver) {
static async create(username, puppeteerOptions, riotserver, hsUrl) {
const browser = await puppeteer.launch(puppeteerOptions);
const page = await browser.newPage();
await page.setViewport({
width: 1280,
height: 800
});
return new RiotSession(browser, page, username, riotserver);
return new RiotSession(browser, page, username, riotserver, hsUrl);
}
async tryGetInnertext(selector) {
@ -161,6 +183,22 @@ module.exports = class RiotSession {
return await this.queryAll(selector);
}
waitForReload(timeout = 5000) {
return new Promise((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
this.browser.removeEventListener('domcontentloaded', callback);
reject(new Error(`timeout of ${timeout}ms for waitForReload elapsed`));
}, timeout);
const callback = async () => {
clearTimeout(timeoutHandle);
resolve();
};
this.page.once('domcontentloaded', callback);
});
}
waitForNewPage(timeout = 5000) {
return new Promise((resolve, reject) => {
const timeoutHandle = setTimeout(() => {

View File

@ -1,49 +0,0 @@
/*
Copyright 2018 New Vector Ltd
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.
*/
const assert = require('assert');
module.exports = async function receiveMessage(session, message) {
session.log.step(`receives message "${message.body}" from ${message.sender}`);
// wait for a response to come in that contains the message
// crude, but effective
await session.page.waitForResponse(async (response) => {
if (response.request().url().indexOf("/sync") === -1) {
return false;
}
const body = await response.text();
if (message.encrypted) {
return body.indexOf(message.sender) !== -1 &&
body.indexOf("m.room.encrypted") !== -1;
} else {
return body.indexOf(message.body) !== -1;
}
});
// wait a bit for the incoming event to be rendered
await session.delay(1000);
let lastTile = await session.query(".mx_EventTile_last");
const senderElement = await lastTile.$(".mx_SenderProfile_name");
const bodyElement = await lastTile.$(".mx_EventTile_body");
const sender = await(await senderElement.getProperty("innerText")).jsonValue();
const body = await(await bodyElement.getProperty("innerText")).jsonValue();
if (message.encrypted) {
const e2eIcon = await lastTile.$(".mx_EventTile_e2eIcon");
assert.ok(e2eIcon);
}
assert.equal(body, message.body);
assert.equal(sender, message.sender);
session.log.done();
}

View File

@ -76,6 +76,13 @@ module.exports = async function changeRoomSettings(session, settings) {
session.log.done();
}
if (settings.alias) {
session.log.step(`sets alias to ${settings.alias}`);
const aliasField = await session.waitAndQuery(".mx_RoomSettings .mx_EditableItemList .mx_EditableItem_editable");
await session.replaceInputText(aliasField, settings.alias);
session.log.done();
}
const saveButton = await session.query(".mx_RoomHeader_wrapper .mx_RoomHeader_textButton");
await saveButton.click();

View File

@ -25,5 +25,7 @@ module.exports = async function sendMessage(session, 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.waitAndQuery(".mx_EventTile_last:not(.mx_EventTile_sending)");
session.log.done();
}
}

View File

@ -16,11 +16,23 @@ limitations under the License.
const assert = require('assert');
module.exports = async function getE2EDeviceFromSettings(session) {
module.exports.enableLazyLoading = async function(session) {
session.log.step(`enables lazy loading of members in the lab settings`);
const settingsButton = await session.query('.mx_BottomLeftMenu_settings');
await settingsButton.click();
const llCheckbox = await session.waitAndQuery("#feature_lazyloading");
await llCheckbox.click();
await session.waitForReload();
const closeButton = await session.waitAndQuery(".mx_RoomHeader_cancelButton");
await closeButton.click();
session.log.done();
}
module.exports.getE2EDeviceFromSettings = async function(session) {
session.log.step(`gets e2e device/key from settings`);
const settingsButton = await session.query('.mx_BottomLeftMenu_settings');
await settingsButton.click();
const deviceAndKey = await session.waitAndQueryAll(".mx_UserSettings_section.mx_UserSettings_cryptoSection code", 1000);
const deviceAndKey = await session.waitAndQueryAll(".mx_UserSettings_section.mx_UserSettings_cryptoSection code");
assert.equal(deviceAndKey.length, 2);
const id = await (await deviceAndKey[0].getProperty("innerText")).jsonValue();
const key = await (await deviceAndKey[1].getProperty("innerText")).jsonValue();

125
src/tests/timeline.js Normal file
View File

@ -0,0 +1,125 @@
/*
Copyright 2018 New Vector Ltd
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.
*/
const assert = require('assert');
module.exports.scrollToTimelineTop = async function(session) {
session.log.step(`scrolls to the top of the timeline`);
await session.page.evaluate(() => {
return Promise.resolve().then(async () => {
const timelineScrollView = document.querySelector(".mx_RoomView .gm-scroll-view");
let timedOut = false;
let timeoutHandle = null;
// set scrollTop to 0 in a loop and check every 50ms
// if content became available (scrollTop not being 0 anymore),
// assume everything is loaded after 3s
do {
if (timelineScrollView.scrollTop !== 0) {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
timeoutHandle = setTimeout(() => timedOut = true, 3000);
timelineScrollView.scrollTop = 0;
} else {
await new Promise((resolve) => setTimeout(resolve, 50));
}
} while (!timedOut)
});
})
session.log.done();
}
module.exports.receiveMessage = async function(session, expectedMessage) {
session.log.step(`receives message "${expectedMessage.body}" from ${expectedMessage.sender}`);
// wait for a response to come in that contains the message
// crude, but effective
await session.page.waitForResponse(async (response) => {
if (response.request().url().indexOf("/sync") === -1) {
return false;
}
const body = await response.text();
if (expectedMessage.encrypted) {
return body.indexOf(expectedMessage.sender) !== -1 &&
body.indexOf("m.room.encrypted") !== -1;
} else {
return body.indexOf(expectedMessage.body) !== -1;
}
});
// wait a bit for the incoming event to be rendered
await session.delay(1000);
const lastTile = await getLastEventTile(session);
const foundMessage = await getMessageFromEventTile(lastTile);
assertMessage(foundMessage, expectedMessage);
session.log.done();
}
module.exports.checkTimelineContains = async function (session, expectedMessages, sendersDescription) {
session.log.step(`checks timeline contains ${expectedMessages.length} ` +
`given messages${sendersDescription ? ` from ${sendersDescription}`:""}`);
const eventTiles = await getAllEventTiles(session);
let timelineMessages = await Promise.all(eventTiles.map((eventTile) => {
return getMessageFromEventTile(eventTile);
}));
//filter out tiles that were not messages
timelineMessages = timelineMessages .filter((m) => !!m);
expectedMessages.forEach((expectedMessage) => {
const foundMessage = timelineMessages.find((message) => {
return message.sender === expectedMessage.sender &&
message.body === expectedMessage.body;
});
assertMessage(foundMessage, expectedMessage);
});
session.log.done();
}
function assertMessage(foundMessage, expectedMessage) {
assert(foundMessage, `message ${JSON.stringify(expectedMessage)} not found in timeline`);
assert.equal(foundMessage.body, expectedMessage.body);
assert.equal(foundMessage.sender, expectedMessage.sender);
if (expectedMessage.hasOwnProperty("encrypted")) {
assert.equal(foundMessage.encrypted, expectedMessage.encrypted);
}
}
function getLastEventTile(session) {
return session.query(".mx_EventTile_last");
}
function getAllEventTiles(session) {
return session.queryAll(".mx_RoomView_MessageList > *");
}
async function getMessageFromEventTile(eventTile) {
const senderElement = await eventTile.$(".mx_SenderProfile_name");
const bodyElement = await eventTile.$(".mx_EventTile_body");
let sender = null;
if (senderElement) {
sender = await(await senderElement.getProperty("innerText")).jsonValue();
}
if (!bodyElement) {
return null;
}
const body = await(await bodyElement.getProperty("innerText")).jsonValue();
const e2eIcon = await eventTile.$(".mx_EventTile_e2eIcon");
return {
sender,
body,
encrypted: !!e2eIcon
};
}

View File

@ -17,6 +17,7 @@ limitations under the License.
const assert = require('assert');
const RiotSession = require('./src/session');
const scenario = require('./src/scenario');
const RestSessionCreator = require('./src/rest/creator');
const program = require('commander');
program
@ -27,6 +28,8 @@ program
.option('--dev-tools', "open chrome devtools in browser window", false)
.parse(process.argv);
const hsUrl = 'http://localhost:5005';
async function runTests() {
let sessions = [];
console.log("running tests ...");
@ -41,15 +44,21 @@ async function runTests() {
options.executablePath = path;
}
const restCreator = new RestSessionCreator(
'synapse/installations/consent',
hsUrl,
__dirname
);
async function createSession(username) {
const session = await RiotSession.create(username, options, program.riotUrl);
const session = await RiotSession.create(username, options, program.riotUrl, hsUrl);
sessions.push(session);
return session;
}
let failure = false;
try {
await scenario(createSession);
await scenario(createSession, restCreator);
} catch(err) {
failure = true;
console.log('failure: ', err);

View File

@ -207,10 +207,10 @@ log_config: "{{SYNAPSE_ROOT}}localhost.log.config"
## Ratelimiting ##
# Number of messages a client can send per second
rc_messages_per_second: 0.2
rc_messages_per_second: 100
# Number of message a client can send before being throttled
rc_message_burst_count: 10.0
rc_message_burst_count: 20.0
# The federation window size in milliseconds
federation_rc_window_size: 1000