From ccd37ac7923637318870bed93424eeaccc89d858 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Tue, 23 May 2017 19:34:01 +0200 Subject: [PATCH] Polishing e2e test API. Added option to parse links and extract parameters from them. Added option to construct parameterizedlinks in "navigate". --- .gitignore | 4 +- lib/tools.js | 1 - package.json | 3 +- test/e2e/helpers/driver.js | 15 ---- test/e2e/helpers/mocha-e2e.js | 88 ++++++++++++++----- test/e2e/helpers/semaphore.js | 35 ++++++++ test/e2e/index.js | 5 +- test/e2e/page-objects/mail.js | 8 +- test/e2e/page-objects/page.js | 75 +++++++++-------- test/e2e/page-objects/subscription.js | 54 +++++++----- test/e2e/page-objects/web.js | 51 +++++++++-- test/e2e/tests/subscription-uc.js | 116 ++++++++++++++++++-------- 12 files changed, 308 insertions(+), 147 deletions(-) delete mode 100644 test/e2e/helpers/driver.js create mode 100644 test/e2e/helpers/semaphore.js diff --git a/.gitignore b/.gitignore index 9bf5b37b..14c576b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ -.idea +/.idea +/last-failed-e2e-test.* + node_modules npm-debug.log .DS_Store diff --git a/lib/tools.js b/lib/tools.js index 901ad0df..2e7a32e2 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -148,7 +148,6 @@ function updateMenu(res) { } function validateEmail(address, checkBlocked, callback) { - let user = (address || '').toString().split('@').shift().toLowerCase().replace(/[^a-z0-9]/g, ''); if (checkBlocked && blockedUsers.indexOf(user) >= 0) { return callback(new Error(util.format(_('Blocked email address "%s"'), address))); diff --git a/package.json b/package.json index 6a419adc..b7ffee19 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "mailparser": "^2.0.5", "mocha": "^3.3.0", "phantomjs": "^2.1.7", - "selenium-webdriver": "^3.4.0" + "selenium-webdriver": "^3.4.0", + "url-pattern": "^1.0.3" }, "optionalDependencies": { "posix": "^4.1.1" diff --git a/test/e2e/helpers/driver.js b/test/e2e/helpers/driver.js deleted file mode 100644 index a9b8444b..00000000 --- a/test/e2e/helpers/driver.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; - -const config = require('./config'); -const webdriver = require('selenium-webdriver'); - -const driver = new webdriver.Builder() - .forBrowser(config.app.seleniumwebdriver.browser || 'phantomjs') - .build(); - -if (global.USE_SHARED_DRIVER === true) { - driver.originalQuit = driver.quit; - driver.quit = () => {}; -} - -module.exports = driver; diff --git a/test/e2e/helpers/mocha-e2e.js b/test/e2e/helpers/mocha-e2e.js index 37f96b68..f781fc0d 100644 --- a/test/e2e/helpers/mocha-e2e.js +++ b/test/e2e/helpers/mocha-e2e.js @@ -2,6 +2,18 @@ const Mocha = require('mocha'); const color = Mocha.reporters.Base.color; +const Semaphore = require('./semaphore'); +const fs = require('fs-extra'); +const config = require('./config'); +const webdriver = require('selenium-webdriver'); + +const driver = new webdriver.Builder() + .forBrowser(config.app.seleniumwebdriver.browser || 'phantomjs') + .build(); + + +const failHandlerRunning = new Semaphore(); + function UseCaseReporter(runner) { Mocha.reporters.Base.call(this, runner); @@ -37,9 +49,6 @@ function UseCaseReporter(runner) { runner.on('use-case end', () => { --indents; - if (indents === 1) { - console.log(); - } }); runner.on('step pass', step => { @@ -71,8 +80,22 @@ function UseCaseReporter(runner) { } }); - runner.on('fail', test => { + runner.on('fail', (test, err) => { + failHandlerRunning.enter(); + (async () => { + const currentUrl = await driver.getCurrentUrl(); + const info = `URL: ${currentUrl}`; + await fs.writeFile('last-failed-e2e-test.info', info); + await fs.writeFile('last-failed-e2e-test.html', await driver.getPageSource()); + await fs.writeFile('last-failed-e2e-test.png', new Buffer(await driver.takeScreenshot(), 'base64')); + failHandlerRunning.exit(); + })(); + console.log(indent() + color('fail', ' %s'), test.title); + console.log(); + console.log(err); + console.log(); + console.log(`Snaphot of and info about the current page are in last-failed-e2e-test.*`); }); runner.on('end', () => { @@ -101,39 +124,61 @@ function UseCaseReporter(runner) { }); } + const mocha = new Mocha() .timeout(120000) - .reporter(UseCaseReporter); + .reporter(UseCaseReporter) + .ui('tdd'); mocha._originalRun = mocha.run; + let runner; mocha.run = fn => { - runner = mocha._originalRun(fn); -} + runner = mocha._originalRun(async () => { + await failHandlerRunning.waitForEmpty(); + await driver.quit(); -async function useCase(name, asyncFn) { - it('Use case: ' + name, async () => { - runner.emit('use-case', {title: name}); - - try { - await asyncFn(); - runner.emit('use-case end'); - } catch (err) { - runner.emit('use-case end'); - console.err(err); - throw err; - } + fn(); }); +}; + + +async function useCaseExec(name, asyncFn) { + runner.emit('use-case', {title: name}); + + try { + await asyncFn(); + runner.emit('use-case end'); + } catch (err) { + runner.emit('use-case end'); + throw err; + } } +function useCase(name, asyncFn) { + if (asyncFn) { + return test('Use case: ' + name, () => useCaseExec(name, asyncFn)); + } else { + // Pending test + return test('Use case: ' + name); + } +} + +useCase.only = (name, asyncFn) => { + return test.only('Use case: ' + name, () => useCaseExec(name, asyncFn)); +}; + +useCase.skip = (name, asyncFn) => { + return test.skip('Use case: ' + name, () => useCaseExec(name, asyncFn)); +}; + async function step(name, asyncFn) { try { await asyncFn(); runner.emit('step pass', {title: name}); } catch (err) { runner.emit('step fail', {title: name}); - console.err(err); throw err; } } @@ -141,5 +186,6 @@ async function step(name, asyncFn) { module.exports = { mocha, useCase, - step + step, + driver }; \ No newline at end of file diff --git a/test/e2e/helpers/semaphore.js b/test/e2e/helpers/semaphore.js new file mode 100644 index 00000000..7c30c900 --- /dev/null +++ b/test/e2e/helpers/semaphore.js @@ -0,0 +1,35 @@ +'use strict'; + +const Promise = require('bluebird'); + +class Semaphore { + constructor() { + this.counter = 0; + } + + enter() { + this.counter++; + } + + exit() { + this.counter--; + } + + async waitForEmpty() { + const self = this; + + function wait(resolve) { + if (self.counter == 0) { + resolve(); + } else { + setTimeout(wait, 500, resolve); + } + } + + return new Promise(resolve => { + setTimeout(wait, 500, resolve); + }) + } +} + +module.exports = Semaphore; \ No newline at end of file diff --git a/test/e2e/index.js b/test/e2e/index.js index 8a655caf..b80bc97f 100644 --- a/test/e2e/index.js +++ b/test/e2e/index.js @@ -1,13 +1,11 @@ 'use strict'; require('./helpers/exit-unless-test'); -const mocha = require('./helpers/mocha-e2e').mocha; +const { mocha, driver } = require('./helpers/mocha-e2e'); const path = require('path'); global.USE_SHARED_DRIVER = true; -const driver = require('./helpers/driver'); - const only = 'only'; const skip = 'skip'; @@ -29,6 +27,5 @@ for (const testSpec of tests) { } mocha.run(failures => { - driver.originalQuit(); process.exit(failures); // exit with non-zero status if there were failures }); diff --git a/test/e2e/page-objects/mail.js b/test/e2e/page-objects/mail.js index e01c7ef4..95450052 100644 --- a/test/e2e/page-objects/mail.js +++ b/test/e2e/page-objects/mail.js @@ -1,14 +1,14 @@ 'use strict'; const config = require('../helpers/config'); - +const driver = require('../helpers/mocha-e2e').driver; const page = require('./page'); -module.exports = (driver, ...extras) => page(driver, { +module.exports = (...extras) => page({ async fetchMail(address) { - await this.driver.sleep(1000); - await this.driver.navigate().to(`${config.mailUrl}/${address}`); + await driver.sleep(1000); + await driver.navigate().to(`${config.mailUrl}/${address}`); await this.waitUntilVisible(); }, diff --git a/test/e2e/page-objects/page.js b/test/e2e/page-objects/page.js index 47d46a16..738325d0 100644 --- a/test/e2e/page-objects/page.js +++ b/test/e2e/page-objects/page.js @@ -5,77 +5,84 @@ const webdriver = require('selenium-webdriver'); const By = webdriver.By; const until = webdriver.until; const fs = require('fs-extra'); +const driver = require('../helpers/mocha-e2e').driver; +const url = require('url'); +const UrlPattern = require('url-pattern'); -module.exports = (driver, ...extras) => Object.assign({ - driver, - +module.exports = (...extras) => Object.assign({ elements: {}, - async element(key) { - return await this.driver.findElement(By.css(this.elements[key] || key)); + async getElement(key) { + return await driver.findElement(By.css(this.elements[key])); + }, + + async getLinkParams(key) { + const elem = await driver.findElement(By.css(this.elements[key])); + + const linkUrl = await elem.getAttribute('href'); + const linkPath = url.parse(linkUrl).path; + + const urlPattern = new UrlPattern(this.links[key]); + + const params = urlPattern.match(linkPath); + if (!params) { + throw new Error(`Cannot match URL pattern ${this.links[key]}`); + } + return params; }, async waitUntilVisible(selector) { - // This is left here to ease debugging - // await this.sleep(2000); - // await this.takeScreenshot('image.png'); - // console.log(await this.source()); - const sel = selector || this.elements[this.elementToWaitFor] || 'body'; - await this.driver.wait(until.elementLocated(By.css(sel)), 10000); + + await driver.wait(until.elementLocated(By.css(sel)), 10000); if (this.url) { await this.ensureUrl(); } }, - async link(key) { - const elem = await this.element(key); - return await elem.getAttribute('href'); - }, - - async submit() { - const submitButton = await this.element('submitButton'); - await submitButton.click(); - }, - async click(key) { - const elem = await this.element(key); + const elem = await this.getElement(key); await elem.click(); }, + async getHref(key) { + const elem = await this.getElement(key); + return await elem.getAttribute('href'); + }, + async getText(key) { - const elem = await this.element(key); + const elem = await this.getElement(key); return await elem.getText(); }, async getValue(key) { - const elem = await this.element(key); + const elem = await this.getElement(key); return await elem.getAttribute('value'); }, - async setValue(key, value) { - const elem = await this.element(key); - await elem.sendKeys(value); - }, - async containsText(str) { - return await this.driver.executeScript(` + return await driver.executeScript(` return (document.documentElement.textContent || document.documentElement.innerText).indexOf('${str}') > -1; `); }, - async source() { - return await this.driver.getPageSource(); + async getSource() { + return await driver.getPageSource(); + }, + + async saveSource(destPath) { + const src = await this.getSource(); + await fs.writeFile(destPath, src); }, async takeScreenshot(destPath) { - const pngData = await this.driver.takeScreenshot(); + const pngData = await driver.takeScreenshot(); const buf = new Buffer(pngData, 'base64'); await fs.writeFile(destPath, buf); }, async sleep(ms) { - await this.driver.sleep(ms); + await driver.sleep(ms); } }, ...extras); diff --git a/test/e2e/page-objects/subscription.js b/test/e2e/page-objects/subscription.js index b80594f8..8d3c0200 100644 --- a/test/e2e/page-objects/subscription.js +++ b/test/e2e/page-objects/subscription.js @@ -4,17 +4,10 @@ const config = require('../helpers/config'); const webBase = require('./web'); const mailBase = require('./mail'); -module.exports = (driver, list) => { +module.exports = list => { - const web = params => webBase(driver, { - async enterEmail(value) { - const emailInput = await this.element('emailInput'); - await emailInput.clear(); - await emailInput.sendKeys(value); - }, - }, params); - - const mail = params => mailBase(driver, params); + const web = params => webBase(params); + const mail = params => mailBase(params); return { webSubscribe: web({ @@ -23,6 +16,8 @@ module.exports = (driver, list) => { elements: { form: `form[action="/subscription/${list.cid}/subscribe"]`, emailInput: '#main-form input[name="email"]', + firstNameInput: '#main-form input[name="first-name"]', + lastNameInput: '#main-form input[name="last-name"]', submitButton: 'a[href="#submit"]' } }), @@ -55,17 +50,40 @@ module.exports = (driver, list) => { elements: { unsubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/unsubscribe/"]`, manageLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/manage/"]` + }, + links: { + unsubscribeLink: `/subscription/${list.cid}/unsubscribe/:ucid`, + manageLink: `/subscription/${list.cid}/manage/:ucid` } }), -/* + webManage: web({ + url: `/subscription/${list.cid}/manage/:ucid`, + elementToWaitFor: 'form', + elements: { + form: `form[action="/subscription/${list.cid}/manage"]`, + emailInput: '#main-form input[name="email"]', + firstNameInput: '#main-form input[name="first-name"]', + lastNameInput: '#main-form input[name="last-name"]', + submitButton: 'a[href="#submit"]' + } + }), + + webUpdatedNotice: web({ + url: `/subscription/${list.cid}/updated-notice`, + elementToWaitFor: 'homepageButton', + elements: { + homepageButton: `a[href="${config.settings['default-homepage']}"]` + } + }), + + /* webUnsubscribe: web({ // FIXME elementToWaitFor: 'submitButton', elements: { submitButton: 'a[href="#submit"]' } }), -*/ webUnsubscribedNotice: web({ url: `/subscription/${list.cid}/unsubscribed-notice`, @@ -81,14 +99,8 @@ module.exports = (driver, list) => { resubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}"]` } }), -/* TODO - webManage: web({ - url: `/subscription/${list.cid}/manage`, - elementToWaitFor: 'homepageButton', - elements: { - homepageButton: `a[href="${config.settings['default-homepage']}"]` - } - }), -*/ + */ + + }; }; diff --git a/test/e2e/page-objects/web.js b/test/e2e/page-objects/web.js index a0281c28..2cead576 100644 --- a/test/e2e/page-objects/web.js +++ b/test/e2e/page-objects/web.js @@ -3,13 +3,30 @@ const config = require('../helpers/config'); const By = require('selenium-webdriver').By; const url = require('url'); - +const UrlPattern = require('url-pattern'); +const driver = require('../helpers/mocha-e2e').driver; const page = require('./page'); -module.exports = (driver, ...extras) => page(driver, { +module.exports = (...extras) => page({ - async navigate(path) { - await this.driver.navigate().to(config.baseUrl + (path || this.url)); + async navigate(pathOrParams) { + let path; + if (typeof pathOrParams === 'string') { + path = pathOrParams; + } else { + const urlPattern = new UrlPattern(this.url); + path = urlPattern.stringify(pathOrParams) + } + + const parsedUrl = url.parse(path); + let absolutePath; + if (parsedUrl.host) { + absolutePath = path; + } else { + absolutePath = config.baseUrl + path; + } + + await driver.navigate().to(absolutePath); await this.waitUntilVisible(); }, @@ -17,28 +34,44 @@ module.exports = (driver, ...extras) => page(driver, { const desiredUrl = path || this.url; if (desiredUrl) { - const currentUrl = url.parse(await this.driver.getCurrentUrl()); - if (this.url !== currentUrl.pathname || config.baseUrl !== `${currentUrl.protocol}//${currentUrl.host}`) { + const currentUrl = url.parse(await driver.getCurrentUrl()); + const urlPattern = new UrlPattern(desiredUrl); + const params = urlPattern.match(currentUrl.pathname); + if (!params || config.baseUrl !== `${currentUrl.protocol}//${currentUrl.host}`) { throw new Error(`Unexpected URL. Expecting ${config.baseUrl}${this.url} got ${currentUrl.protocol}//${currentUrl.host}/${currentUrl.pathname}`); } + + this.params = params; } }, + async submit() { + const submitButton = await this.getElement('submitButton'); + await submitButton.click(); + }, + async waitForFlash() { await this.waitUntilVisible('div.alert:not(.js-warning)'); }, - async getFlash() { - const elem = await this.driver.findElement(By.css('div.alert:not(.js-warning)')); + async flash() { + const elem = await driver.findElement(By.css('div.alert:not(.js-warning)')); return await elem.getText(); }, async clearFlash() { - await this.driver.executeScript(` + await driver.executeScript(` var elements = document.getElementsByClassName('alert'); while(elements.length > 0){ elements[0].parentNode.removeChild(elements[0]); } `); + }, + + async setValue(key, value) { + const elem = await this.getElement(key); + await elem.clear(); + await elem.sendKeys(value); } + }, ...extras); diff --git a/test/e2e/tests/subscription-uc.js b/test/e2e/tests/subscription-uc.js index 953b5cf4..38913e05 100644 --- a/test/e2e/tests/subscription-uc.js +++ b/test/e2e/tests/subscription-uc.js @@ -1,61 +1,74 @@ 'use strict'; const config = require('../helpers/config'); -const { useCase, step } = require('../helpers/mocha-e2e'); +const { useCase, step, driver } = require('../helpers/mocha-e2e'); const shortid = require('shortid'); const expect = require('chai').expect; -const driver = require('../helpers/driver'); -const page = require('../page-objects/subscription')(driver, config.lists.one); +const page = require('../page-objects/subscription')(config.lists.one); function generateEmail() { return 'keep.' + shortid.generate() + '@mailtrain.org'; } -async function subscribe(testUserEmail) { - const subscription = { - email: testUserEmail - }; - - await step('User navigates to list subscription page', async () => { +async function subscribe(subscription) { + await step('User navigates to list subscription page.', async () => { await page.webSubscribe.navigate(); }); - await step('User submits a valid email', async () => { - await page.webSubscribe.enterEmail(testUserEmail); + await step('User submits a valid email and other subscription info.', async () => { + await page.webSubscribe.setValue('emailInput', subscription.email); + + if (subscription.firstName) { + await page.webSubscribe.setValue('firstNameInput', subscription.firstName); + } + + if (subscription.lastName) { + await page.webSubscribe.setValue('lastNameInput', subscription.lastName); + } + await page.webSubscribe.submit(); }); - await step('System shows a notice that further instructions are in the email', async () => { + await step('System shows a notice that further instructions are in the email.', async () => { await page.webConfirmSubscriptionNotice.waitUntilVisible(); }); - await step('System sends an email with a link to confirm the subscription', async () => { - await page.mailConfirmSubscription.fetchMail(testUserEmail); + await step('System sends an email with a link to confirm the subscription.', async () => { + await page.mailConfirmSubscription.fetchMail(subscription.email); }); await step('User clicks confirm subscription in the email', async () => { await page.mailConfirmSubscription.click('confirmLink'); }); - await step('System shows a notice that subscription has been confirmed', async () => { + await step('System shows a notice that subscription has been confirmed.', async () => { await page.webSubscribedNotice.waitUntilVisible(); }); - await step('System sends an email with subscription confirmation', async () => { - await page.mailSubscriptionConfirmed.fetchMail(testUserEmail); - subscription.unsubscribeLink = await page.mailSubscriptionConfirmed.link('unsubscribeLink'); - subscription.manageLink = await page.mailSubscriptionConfirmed.link('manageLink'); + await step('System sends an email with subscription confirmation.', async () => { + await page.mailSubscriptionConfirmed.fetchMail(subscription.email); + subscription.unsubscribeLink = await page.mailSubscriptionConfirmed.getHref('unsubscribeLink'); + subscription.manageLink = await page.mailSubscriptionConfirmed.getHref('manageLink'); + + const unsubscribeParams = await page.mailSubscriptionConfirmed.getLinkParams('unsubscribeLink'); + const manageParams = await page.mailSubscriptionConfirmed.getLinkParams('manageLink'); + console.log(unsubscribeParams); + console.log(manageParams); + expect(unsubscribeParams.ucid).to.equal(manageParams.ucid); + subscription.ucid = unsubscribeParams.ucid; }); return subscription; } -describe('Subscription use-cases', function() { +suite('Subscription use-cases', function() { before(() => driver.manage().deleteAllCookies()); useCase('Subscription to a public list (main scenario)', async () => { - await subscribe(generateEmail()); + await subscribe({ + email: generateEmail() + }); }); useCase('Subscription to a public list (invalid email)', async () => { @@ -63,44 +76,75 @@ describe('Subscription use-cases', function() { await page.webSubscribe.navigate(); }); - await step('User submits an invalid email', async () => { - await page.webSubscribe.enterEmail('foo@bar.nope'); + await step('User submits an invalid email.', async () => { + await page.webSubscribe.setValue('emailInput', 'foo@bar.nope'); await page.webSubscribe.submit(); }); - await step('System shows a flash notice that email is invalid', async () => { + await step('System shows a flash notice that email is invalid.', async () => { await page.webSubscribe.waitForFlash(); expect(await page.webSubscribe.getFlash()).to.contain('Invalid email address'); }); }); - useCase('Unsubscription from list #1 (one-step, no form)', async () => { - const subscription = await subscribe(generateEmail()); + useCase('Unsubscription from list #1 (one-step, no form).', async () => { + const subscription = await subscribe({ + email: generateEmail() + }); - await step('User clicks the unsubscribe button', async () => { + await step('User clicks the unsubscribe button.', async () => { await page.mailSubscriptionConfirmed.click('unsubscribeLink'); }); - await step('System show a notice that confirms unsubscription', async () => { + await step('System show a notice that confirms unsubscription.', async () => { await page.webUnsubscribedNotice.waitUntilVisible(); }); - await step('System sends an email that confirms unsubscription', async () => { + await step('System sends an email that confirms unsubscription.', async () => { await page.mailUnsubscriptionConfirmed.fetchMail(subscription.email); }); }); -/* - useCase('Change email in list #1 (one-step, no form)', async () => { - const subscription = await subscribe(generateEmail()); - await step('User clicks the manage subscription button', async () => { + useCase.only('Change email in list #1 (one-step, no form)', async () => { + const subscription = await subscribe({ + email: generateEmail(), + firstName: 'John', + lastName: 'Doe' + }); + + await step('User clicks the manage subscription button.', async () => { await page.mailSubscriptionConfirmed.click('manageLink'); }); - await step('System show a notice that confirms unsubscription', async () => { + await step('Systems shows a form to change subscription details. The form contains data entered during subscription.', async () => { await page.webManage.waitUntilVisible(); + expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email); + expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName); + expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName); }); + + await step('User enters another name and submits the form.', async () => { + subscription.firstName = 'Adam'; + subscription.lastName = 'B'; + await page.webManage.setValue('firstNameInput', subscription.firstName); + await page.webManage.setValue('lastNameInput', subscription.lastName); + await page.webManage.submit(); + }); + + await step('Systems shows a notice that profile has been updated.', async () => { + await page.webUpdatedNotice.waitUntilVisible(); + }); + + await step('User navigates to manage subscription again.', async () => { + await page.webManage.navigate(subscription.manageLink); + // await page.webManage.navigate({ ucid: subscription.ucid }); + }); + + await step('Systems shows a form with the changes made previously.', async () => { + expect(await page.webManage.getValue('emailInput')).to.equal(subscription.email); + expect(await page.webManage.getValue('firstNameInput')).to.equal(subscription.firstName); + expect(await page.webManage.getValue('lastNameInput')).to.equal(subscription.lastName); + }); + }); -*/ - after(() => driver.quit()); });