session-desktop/ts/test/session/integration/common.ts

721 lines
22 KiB
TypeScript

// tslint:disable: no-implicit-dependencies
import { Application } from 'spectron';
import path from 'path';
import url from 'url';
import http from 'http';
import fse from 'fs-extra';
import { exec } from 'child_process';
import chai from 'chai';
import chaiAsPromised from 'chai-as-promised';
import CommonPage from './page-objects/common.page';
import RegistrationPage from './page-objects/registration.page';
import ConversationPage from './page-objects/conversation.page';
import SettingsPage from './page-objects/settings.page';
chai.should();
chai.use(chaiAsPromised as any);
chai.config.includeStack = true;
// FIXME audric
// From https://github.com/chaijs/chai/issues/200
chai.use((_chai, _) => {
_chai.Assertion.addMethod('withMessage', (msg: string) => {
_.flag(Common, 'message', msg);
});
});
const STUB_SNODE_SERVER_PORT = 3000;
const ENABLE_LOG = false;
// tslint:disable-next-line: no-unnecessary-class
export class Common {
/* ************** USERS ****************** */
public static readonly TEST_RECOVERY_PHRASE_1 =
'faxed mechanic mocked agony unrest loincloth pencil eccentric boyfriend oasis speedy ribbon faxed';
public static readonly TEST_PUBKEY1 =
'0552b85a43fb992f6bdb122a5a379505a0b99a16f0628ab8840249e2a60e12a413';
public static readonly TEST_DISPLAY_NAME1 = 'tester_Alice';
public static readonly TEST_RECOVERY_PHRASE_2 =
'guide inbound jerseys bays nouns basin sulking awkward stockpile ostrich ascend pylons ascend';
public static readonly TEST_PUBKEY2 =
'054e1ca8681082dbd9aad1cf6fc89a32254e15cba50c75b5a73ac10a0b96bcbd2a';
public static readonly TEST_DISPLAY_NAME2 = 'tester_Bob';
public static readonly TEST_RECOVERY_PHRASE_3 =
'alpine lukewarm oncoming blender kiwi fuel lobster upkeep vogue simplest gasp fully simplest';
public static readonly TEST_PUBKEY3 =
'05f8662b6e83da5a31007cc3ded44c601f191e07999acb6db2314a896048d9036c';
public static readonly TEST_DISPLAY_NAME3 = 'tester_Charlie';
/* ************** OPEN GROUPS ****************** */
public static readonly VALID_GROUP_URL = 'https://chat.getsession.org';
public static readonly VALID_GROUP_URL2 = 'https://chat-dev.lokinet.org';
public static readonly VALID_GROUP_NAME = 'Session Public Chat';
public static readonly VALID_GROUP_NAME2 = 'Loki Dev Chat';
/* ************** CLOSED GROUPS ****************** */
public static readonly VALID_CLOSED_GROUP_NAME1 = 'Closed Group 1';
public static USER_DATA_ROOT_FOLDER = '';
private static stubSnode: any;
private static messages: any;
private static fileServer: any;
// tslint:disable: await-promise
// tslint:disable: no-console
public static async timeout(ms: number) {
// tslint:disable-next-line: no-string-based-set-timeout
return new Promise(resolve => setTimeout(resolve, ms));
}
public static async closeToast(app: Application) {
await app.client.element(CommonPage.toastCloseButton).click();
}
// a wrapper to work around electron/spectron bug
public static async setValueWrapper(
app: Application,
selector: any,
value: string
) {
// keys, setValue and addValue hang on certain platforms
if (process.platform === 'darwin') {
await app.client.execute(
(slctr, val) => {
// eslint-disable-next-line no-undef
const iter = document.evaluate(
slctr,
document,
null,
XPathResult.UNORDERED_NODE_ITERATOR_TYPE,
null
);
const elem = iter.iterateNext() as any;
if (elem) {
elem.value = val;
} else {
console.error('Cant find', slctr, elem, iter);
}
},
selector,
value
);
// let session js detect the text change
await app.client.element(selector).click();
} else {
// Linux & Windows don't require wrapper
await app.client.element(selector).setValue(value);
}
}
public static async startApp(environment = 'test-integration-session') {
const env = environment.startsWith('test-integration')
? 'test-integration'
: environment;
const instance = environment.replace('test-integration-', '');
const app1 = new Application({
path: path.join(
__dirname,
'..',
'..',
'..',
'..',
'node_modules',
'.bin',
'electron'
),
args: ['.'],
env: {
NODE_ENV: env,
NODE_APP_INSTANCE: instance,
USE_STUBBED_NETWORK: true,
ELECTRON_ENABLE_LOGGING: true,
ELECTRON_ENABLE_STACK_DUMPING: true,
ELECTRON_DISABLE_SANDBOX: 1,
},
requireName: 'electronRequire',
// chromeDriverLogPath: '../chromedriverlog.txt',
chromeDriverArgs: [
`remote-debugging-port=${Math.floor(
// tslint:disable-next-line: insecure-random
Math.random() * (9999 - 9000) + 9000
)}`,
],
});
// FIXME audric
// chaiAsPromised.transferPromiseness = app1.transferPromiseness;
await app1.start();
await app1.client.waitUntilWindowLoaded();
return app1;
}
public static async startApp2() {
const app2 = await Common.startApp('test-integration-session-2');
return app2;
}
public static async stopApp(app1: Application) {
if (app1 && app1.isRunning()) {
await app1.stop();
}
}
public static async killallElectron() {
// rtharp - my 2nd client on MacOs needs: pkill -f "node_modules/.bin/electron"
// node_modules/electron/dist/electron is node_modules/electron/dist/Electron.app on MacOS
const killStr =
process.platform === 'win32'
? 'taskkill /im electron.exe /t /f'
: 'pkill -f "node_modules/electron/dist/electron" | pkill -f "node_modules/.bin/electron"';
return new Promise(resolve => {
exec(killStr, (_err, stdout, stderr) => {
resolve({ stdout, stderr });
});
});
}
public static async rmFolder(folder: string) {
await fse.remove(folder);
}
public static async startAndAssureCleanedApp2() {
const app2 = await Common.startAndAssureCleanedApp(
'test-integration-session-2'
);
return app2;
}
public static async startAndAssureCleanedApp(
env = 'test-integration-session'
) {
const userData = path.join(Common.USER_DATA_ROOT_FOLDER, `Session-${env}`);
await Common.rmFolder(userData);
const app1 = await Common.startApp(env);
await app1.client.waitForExist(
RegistrationPage.registrationTabSignIn,
4000
);
return app1;
}
public static async startAndStub({
recoveryPhrase,
displayName,
env = 'test-integration-session',
}: {
recoveryPhrase: string;
displayName: string;
env?: string;
}) {
const app = await Common.startAndAssureCleanedApp(env);
Common.startStubSnodeServer();
if (recoveryPhrase && displayName) {
await Common.restoreFromRecoveryPhrase(app, recoveryPhrase, displayName);
// not sure we need Common - rtharp.
await Common.timeout(2000);
}
return app;
}
public static async startAndStubN(props: any, n: number) {
// Make app with stub as number n
const appN = await Common.startAndStub({
env: `test-integration-session-${n}`,
...props,
});
return appN;
}
public static async restoreFromRecoveryPhrase(
app: Application,
recoveryPhrase: string,
displayName: string
) {
await app.client.element(RegistrationPage.registrationTabSignIn).click();
await app.client.element(RegistrationPage.restoreFromSeedMode).click();
await Common.setValueWrapper(
app,
RegistrationPage.recoveryPhraseInput,
recoveryPhrase
);
await Common.setValueWrapper(
app,
RegistrationPage.displayNameInput,
displayName
);
// await app.client.element(RegistrationPage.continueSessionButton).click();
await app.client.keys('Enter');
await app.client.waitForExist(
RegistrationPage.conversationListContainer,
4000
);
}
public static async makeFriends(
app1: Application,
client2: [Application, string]
) {
const [_, pubkey2] = client2;
/** add each other as friends */
const textMessage = Common.generateSendMessageText();
await app1.client.element(ConversationPage.contactsButtonSection).click();
await app1.client.element(ConversationPage.addContactButton).click();
await Common.setValueWrapper(
app1,
ConversationPage.sessionIDInput,
pubkey2
);
await app1.client.element(ConversationPage.nextButton).click();
await app1.client.waitForExist(
ConversationPage.sendMessageTextareaAndMessage,
1000
);
// send a text message to that user (will be a friend request)
await Common.setValueWrapper(
app1,
ConversationPage.sendMessageTextareaAndMessage,
textMessage
);
await app1.client.keys('Enter');
await app1.client.waitForExist(
ConversationPage.existingSendMessageText(textMessage),
1000
);
}
public static async startAppsAsFriends() {
const app1Props = {
recoveryPhrase: Common.TEST_RECOVERY_PHRASE_1,
displayName: Common.TEST_DISPLAY_NAME1,
stubSnode: true,
};
const app2Props = {
recoveryPhrase: Common.TEST_RECOVERY_PHRASE_2,
displayName: Common.TEST_DISPLAY_NAME2,
stubSnode: true,
};
const [app1, app2] = await Promise.all([
Common.startAndStub(app1Props),
Common.startAndStubN(app2Props, 2),
]);
await Common.makeFriends(app1, [app2, Common.TEST_PUBKEY2]);
return [app1, app2];
}
public static async addFriendToNewClosedGroup(members: Array<Application>) {
const [app, ...others] = members;
await app.client
.element(ConversationPage.conversationButtonSection)
.click();
await app.client.element(ConversationPage.createClosedGroupButton).click();
await Common.setValueWrapper(
app,
ConversationPage.closedGroupNameTextarea,
Common.VALID_CLOSED_GROUP_NAME1
);
await app.client
.element(ConversationPage.closedGroupNameTextarea)
.getValue()
.should.eventually.equal(Common.VALID_CLOSED_GROUP_NAME1);
// Common assumes that app does not have any other friends
for (let i = 0; i < others.length; i += 1) {
// eslint-disable-next-line no-await-in-loop
await app.client
.element(ConversationPage.createClosedGroupMemberItem(i))
.isVisible().should.eventually.be.true;
// eslint-disable-next-line no-await-in-loop
await app.client
.element(ConversationPage.createClosedGroupMemberItem(i))
.click();
}
await app.client
.element(ConversationPage.createClosedGroupMemberItemSelected)
.isVisible().should.eventually.be.true;
// trigger the creation of the group
await app.client
.element(ConversationPage.validateCreationClosedGroupButton)
.click();
await app.client.waitForExist(
ConversationPage.sessionToastGroupCreatedSuccess,
1000
);
await app.client.isExisting(
ConversationPage.headerTitleGroupName(Common.VALID_CLOSED_GROUP_NAME1)
).should.eventually.be.true;
await app.client
.element(ConversationPage.headerTitleMembers(members.length))
.isVisible().should.eventually.be.true;
// validate overlay is closed
await app.client
.isExisting(ConversationPage.leftPaneOverlay)
.should.eventually.be.equal(false);
// move back to the conversation section
await app.client
.element(ConversationPage.conversationButtonSection)
.click();
// validate open chat has been added
await app.client.isExisting(
ConversationPage.rowOpenGroupConversationName(
Common.VALID_CLOSED_GROUP_NAME1
)
).should.eventually.be.true;
await Promise.all(
others.map(async otherApp => {
// next check that other members have been invited and have the group in their conversations
await otherApp.client.waitForExist(
ConversationPage.rowOpenGroupConversationName(
Common.VALID_CLOSED_GROUP_NAME1
),
6000
);
// open the closed group conversation on otherApp
await otherApp.client
.element(ConversationPage.conversationButtonSection)
.click();
await Common.timeout(500);
await otherApp.client
.element(
ConversationPage.rowOpenGroupConversationName(
Common.VALID_CLOSED_GROUP_NAME1
)
)
.click();
})
);
}
public static async linkApp2ToApp(
app1: Application,
app2: Application,
app1Pubkey: string
) {
// app needs to be logged in as user1 and app2 needs to be logged out
// start the pairing dialog for the first app
await app1.client.element(SettingsPage.settingsButtonSection).click();
await app1.client.isVisible(ConversationPage.noPairedDeviceMessage);
// we should not find the linkDeviceButtonDisabled button (as DISABLED)
await app1.client.isExisting(ConversationPage.linkDeviceButtonDisabled)
.should.eventually.be.false;
await app1.client.element(ConversationPage.linkDeviceButton).click();
// validate device pairing dialog is shown and has a qrcode
await app1.client.isVisible(ConversationPage.qrImageDiv);
// next trigger the link request from the app2 with the app1 pubkey
await app2.client.element(RegistrationPage.registrationTabSignIn).click();
await app2.client.element(RegistrationPage.linkDeviceMode).click();
await Common.setValueWrapper(
app2,
RegistrationPage.textareaLinkDevicePubkey,
app1Pubkey
);
await app2.client.element(RegistrationPage.linkDeviceTriggerButton).click();
await app1.client.waitForExist(SettingsPage.secretWordsTextInDialog, 7000);
const secretWordsapp1 = await app1.client
.element(SettingsPage.secretWordsTextInDialog)
.getText();
await app1.client.waitForExist(RegistrationPage.linkWithThisDevice, 10000);
await app2.client
.element(RegistrationPage.secretWordsText)
.getText()
.should.eventually.be.equal(secretWordsapp1);
await app1.client.element(ConversationPage.allowPairingButton).click();
await app1.client.element(ConversationPage.okButton).click();
// validate device paired in settings list with correct secrets
await app1.client.waitForExist(
ConversationPage.devicePairedDescription(secretWordsapp1),
2000
);
await app1.client.isExisting(ConversationPage.unpairDeviceButton).should
.eventually.be.true;
await app1.client.isExisting(ConversationPage.linkDeviceButtonDisabled)
.should.eventually.be.true;
// validate app2 (secondary device) is linked successfully
await app2.client.waitForExist(
RegistrationPage.conversationListContainer,
4000
);
// validate primary pubkey of app2 is the same that in app1
await app2.webContents
.executeJavaScript("window.storage.get('primaryDevicePubKey')")
.should.eventually.be.equal(app1Pubkey);
}
public static async triggerUnlinkApp2FromApp(
app1: Application,
app2: Application
) {
// check app2 is loggedin
await app2.client.isExisting(RegistrationPage.conversationListContainer)
.should.eventually.be.true;
await app1.client.element(SettingsPage.settingsButtonSection).click();
await app1.client.isExisting(ConversationPage.linkDeviceButtonDisabled)
.should.eventually.be.true;
// click the unlink button
await app1.client.element(ConversationPage.unpairDeviceButton).click();
await app1.client.element(ConversationPage.validateUnpairDevice).click();
await app1.client.waitForExist(
ConversationPage.noPairedDeviceMessage,
5000
);
await app1.client.element(ConversationPage.linkDeviceButton).isEnabled()
.should.eventually.be.true;
// let time to app2 to catch the event and restart dropping its data
await Common.timeout(5000);
// check that the app restarted
// (did not find a better way than checking the app no longer being accessible)
let isApp2Joinable = true;
try {
await app2.client.isExisting(RegistrationPage.registrationTabSignIn)
.should.eventually.be.true;
} catch (err) {
// if we get an error here, it means Spectron is lost.
// Common is a good thing because it means app2 restarted
isApp2Joinable = false;
}
if (isApp2Joinable) {
throw new Error(
'app2 is still joinable so it did not restart, so it did not unlink correctly'
);
}
}
public static async sendMessage(
app: Application,
messageText: string,
fileLocation?: string
) {
await Common.setValueWrapper(
app,
ConversationPage.sendMessageTextarea,
messageText
);
await app.client
.element(ConversationPage.sendMessageTextarea)
.getValue()
.should.eventually.equal(messageText);
// attach a file
if (fileLocation) {
await Common.setValueWrapper(
app,
ConversationPage.attachmentInput,
fileLocation
);
}
// send message
await app.client.element(ConversationPage.sendMessageTextarea).click();
await app.client.keys('Enter');
}
public static generateSendMessageText(): string {
return `Test message from integration tests ${Date.now()}`;
}
public static startStubSnodeServer() {
if (!Common.stubSnode) {
Common.messages = {};
Common.stubSnode = http.createServer((request: any, response: any) => {
const { query } = url.parse(request.url, true);
const { pubkey, data, timestamp } = query;
if (!pubkey) {
console.warn('NO PUBKEY');
response.writeHead(400, { 'Content-Type': 'text/html' });
response.end();
return;
}
if (Array.isArray(pubkey)) {
console.error('pubkey cannot be an array');
response.writeHead(400, { 'Content-Type': 'text/html' });
response.end();
return;
}
if (Array.isArray(data)) {
console.error('data cannot be an array');
response.writeHead(400, { 'Content-Type': 'text/html' });
response.end();
return;
}
if (request.method === 'POST') {
if (ENABLE_LOG) {
console.warn(
'POST',
pubkey.substr(2, 3),
data.substr(4, 10),
timestamp
);
}
let ori = Common.messages[pubkey];
if (!Common.messages[pubkey]) {
ori = [];
}
Common.messages[pubkey] = [...ori, { data, timestamp }];
response.writeHead(200, { 'Content-Type': 'text/html' });
response.end();
} else {
const retrievedMessages = { messages: Common.messages[pubkey] || [] };
if (ENABLE_LOG) {
const messages = retrievedMessages.messages.map((m: any) =>
m.data.substr(4, 10)
);
console.warn('GET', pubkey.substr(2, 3), messages);
}
response.writeHead(200, { 'Content-Type': 'application/json' });
response.write(JSON.stringify(retrievedMessages));
response.end();
}
});
Common.startLocalFileServer();
Common.stubSnode.listen(STUB_SNODE_SERVER_PORT);
} else {
Common.messages = {};
}
}
public static startLocalFileServer() {
if (!Common.fileServer) {
// be sure to run `git submodule update --init && cd session-file-server && yarn install; cd -`
// eslint-disable-next-line global-require
// tslint:disable-next-line: no-require-imports
Common.fileServer = require('../../../../session-file-server/app');
}
}
public static async joinOpenGroup(
app: Application,
openGroupUrl: string,
name: string
) {
await app.client
.element(ConversationPage.conversationButtonSection)
.click();
await app.client.element(ConversationPage.joinOpenGroupButton).click();
await Common.setValueWrapper(
app,
ConversationPage.openGroupInputUrl,
openGroupUrl
);
await app.client
.element(ConversationPage.openGroupInputUrl)
.getValue()
.should.eventually.equal(openGroupUrl);
await app.client.element(ConversationPage.joinOpenGroupButton).click();
await app.client.waitForExist(
ConversationPage.sessionToastJoinOpenGroup,
2 * 1000
);
// account for slow home internet connection delays...
await app.client.waitForExist(
ConversationPage.sessionToastJoinOpenGroupSuccess,
20 * 1000
);
// validate overlay is closed
await app.client.isExisting(ConversationPage.leftPaneOverlay).should
.eventually.be.false;
// validate open chat has been added
await app.client.waitForExist(
ConversationPage.rowOpenGroupConversationName(name),
20 * 1000
);
}
public static async stopStubSnodeServer() {
if (Common.stubSnode) {
await Common.stubSnode.close();
Common.stubSnode = null;
}
}
/**
* Search for a string in logs
* @param app the render logs to search in
* @param str the string to search (not regex)
* Note: getRenderProcessLogs() clears the app logs each calls.
*/
public static logsContains(
renderLogs: Array<{ message: string }>,
str: string,
count?: number
) {
const foundLines = renderLogs.filter(log => log.message.includes(str));
// tslint:disable-next-line: no-unused-expression
chai.expect(
foundLines.length > 0,
`'${str}' not found in logs but was expected`
).to.be.true;
if (count) {
chai
.expect(
foundLines.length,
`'${str}' found but not the correct number of times`
)
.to.be.equal(count);
}
}
}