diff --git a/.gitignore b/.gitignore index c7687df8..9bf5b37b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.idea node_modules npm-debug.log .DS_Store diff --git a/app.js b/app.js index 1143df71..c6578e60 100644 --- a/app.js +++ b/app.js @@ -184,8 +184,9 @@ app.use((req, res, next) => { res.locals.customScripts = config.customscripts || []; let bodyClasses = []; - app.get('env') === 'test' && bodyClasses.push('page--' + (req.path.substring(1).replace(/\//g, '--') || 'home')); - req.user && bodyClasses.push('logged-in user-' + req.user.username); + if (req.user) { + bodyClasses.push('logged-in user-' + req.user.username); + } res.locals.bodyClass = bodyClasses.join(' '); settingsModel.list(['ua_code', 'shoutout'], (err, configItems) => { diff --git a/package.json b/package.json index 29c98088..6a419adc 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "sqldumptest": "NODE_ENV=test DUMP_NAME_SUFFIX=-test npm run sqldump", "sqlresettest": "NODE_ENV=test npm run sqldrop && NODE_ENV=test npm run sqlinit", "starttest": "NODE_ENV=test node index.js", - "_e2e": "NODE_ENV=test mocha test/e2e/index.js", + "_e2e": "NODE_ENV=test node test/e2e/index.js", "e2e": "npm run sqlresettest && npm run _e2e" }, "repository": { @@ -70,6 +70,7 @@ "faker": "^4.1.0", "feedparser": "^2.1.0", "file-type": "^4.1.0", + "fs-extra": "^3.0.1", "geoip-ultralight": "^0.1.5", "gettext-parser": "^1.2.2", "gm": "^1.23.0", diff --git a/test/e2e/helpers/config.js b/test/e2e/helpers/config.js index 71726ff7..a9d191be 100644 --- a/test/e2e/helpers/config.js +++ b/test/e2e/helpers/config.js @@ -5,6 +5,7 @@ const config = require('config'); module.exports = { app: config, baseUrl: 'http://localhost:' + config.www.port, + mailUrl: 'http://localhost:' + config.testserver.mailboxserverport, users: { admin: { username: 'admin', diff --git a/test/e2e/helpers/mocha-e2e.js b/test/e2e/helpers/mocha-e2e.js new file mode 100644 index 00000000..37f96b68 --- /dev/null +++ b/test/e2e/helpers/mocha-e2e.js @@ -0,0 +1,145 @@ +'use strict'; + +const Mocha = require('mocha'); +const color = Mocha.reporters.Base.color; + +function UseCaseReporter(runner) { + Mocha.reporters.Base.call(this, runner); + + const self = this; + let indents = 0; + + function indent () { + return Array(indents).join(' '); + } + + runner.on('start', function () { + console.log(); + }); + + runner.on('suite', suite => { + ++indents; + console.log(color('suite', '%s%s'), indent(), suite.title); + }); + + runner.on('suite end', () => { + --indents; + if (indents === 1) { + console.log(); + } + }); + + runner.on('use-case', useCase => { + ++indents; + console.log(); + console.log(color('suite', '%sUse case: %s'), indent(), useCase.title); + }); + + runner.on('use-case end', () => { + --indents; + if (indents === 1) { + console.log(); + } + }); + + runner.on('step pass', step => { + console.log(indent() + color('checkmark', ' ' + Mocha.reporters.Base.symbols.ok) + color('pass', ' %s'), step.title); + }); + + runner.on('step fail', step => { + console.log(indent() + color('fail', ' %s'), step.title); + }); + + runner.on('pending', test => { + const fmt = indent() + color('pending', ' - %s'); + console.log(fmt, test.title); + }); + + runner.on('pass', test => { + let fmt; + if (test.speed === 'fast') { + fmt = indent() + + color('checkmark', ' ' + Mocha.reporters.Base.symbols.ok) + + color('pass', ' %s'); + console.log(fmt, test.title); + } else { + fmt = indent() + + color('checkmark', ' ' + Mocha.reporters.Base.symbols.ok) + + color('pass', ' %s') + + color(test.speed, ' (%dms)'); + console.log(fmt, test.title, test.duration); + } + }); + + runner.on('fail', test => { + console.log(indent() + color('fail', ' %s'), test.title); + }); + + runner.on('end', () => { + const stats = self.stats; + let fmt; + + console.log(); + + // passes + fmt = color('bright pass', ' ') + color('green', ' %d passing'); + console.log(fmt, stats.passes); + + // pending + if (stats.pending) { + fmt = color('pending', ' ') + color('pending', ' %d pending'); + console.log(fmt, stats.pending); + } + + // failures + if (stats.failures) { + fmt = color('fail', ' %d failing'); + console.log(fmt, stats.failures); + } + + console.log(); + }); +} + +const mocha = new Mocha() + .timeout(120000) + .reporter(UseCaseReporter); + +mocha._originalRun = mocha.run; + +let runner; +mocha.run = fn => { + runner = mocha._originalRun(fn); +} + +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; + } + }); +} + +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; + } +} + +module.exports = { + mocha, + useCase, + step +}; \ No newline at end of file diff --git a/test/e2e/index.js b/test/e2e/index.js index d78f5819..8a655caf 100644 --- a/test/e2e/index.js +++ b/test/e2e/index.js @@ -1,36 +1,34 @@ 'use strict'; require('./helpers/exit-unless-test'); +const mocha = require('./helpers/mocha-e2e').mocha; +const path = require('path'); global.USE_SHARED_DRIVER = true; const driver = require('./helpers/driver'); + const only = 'only'; const skip = 'skip'; - - let tests = [ - ['tests/login'], - ['tests/subscription'] + 'login', + 'subscription', + ['subscription-uc', only] ]; - - -tests = tests.filter(t => t[1] !== skip); - -if (tests.some(t => t[1] === only)) { - tests = tests.filter(t => t[1] === only); +tests = tests.map(testSpec => (testSpec.constructor === Array ? testSpec : [testSpec])); +tests = tests.filter(testSpec => testSpec[1] !== skip); +if (tests.some(testSpec => testSpec[1] === only)) { + tests = tests.filter(testSpec => testSpec[1] === only); } -describe('e2e', function() { - this.timeout(10000); +for (const testSpec of tests) { + const testPath = path.join(__dirname, 'tests', testSpec[0] + '.js'); + mocha.addFile(testPath); +} - tests.forEach(t => { - describe(t[0], () => { - require('./' + t[0]); // eslint-disable-line global-require - }); - }); - - after(() => driver.originalQuit()); +mocha.run(failures => { + driver.originalQuit(); + process.exit(failures); // exit with non-zero status if there were failures }); diff --git a/test/e2e/page-objects/flash.js b/test/e2e/page-objects/flash.js deleted file mode 100644 index ee42f4bd..00000000 --- a/test/e2e/page-objects/flash.js +++ /dev/null @@ -1,21 +0,0 @@ -'use strict'; - -const page = require('./page'); - -module.exports = driver => Object.assign(page(driver), { - elementToWaitFor: 'alert', - elements: { - alert: 'div.alert:not(.js-warning)' - }, - getText() { - return this.element('alert').getText(); - }, - clear() { - return this.driver.executeScript(` - var elements = document.getElementsByClassName('alert'); - while(elements.length > 0){ - elements[0].parentNode.removeChild(elements[0]); - } - `); - } -}); diff --git a/test/e2e/page-objects/home.js b/test/e2e/page-objects/home.js index 2fccae24..bf322fc8 100644 --- a/test/e2e/page-objects/home.js +++ b/test/e2e/page-objects/home.js @@ -1,6 +1,6 @@ 'use strict'; -const page = require('./page'); +const page = require('./web'); module.exports = driver => Object.assign(page(driver), { url: '/', diff --git a/test/e2e/page-objects/mail.js b/test/e2e/page-objects/mail.js new file mode 100644 index 00000000..e01c7ef4 --- /dev/null +++ b/test/e2e/page-objects/mail.js @@ -0,0 +1,19 @@ +'use strict'; + +const config = require('../helpers/config'); + +const page = require('./page'); + +module.exports = (driver, ...extras) => page(driver, { + + async fetchMail(address) { + await this.driver.sleep(1000); + await this.driver.navigate().to(`${config.mailUrl}/${address}`); + await this.waitUntilVisible(); + }, + + async ensureUrl(path) { + throw new Error('Unsupported method.'); + }, + +}, ...extras); diff --git a/test/e2e/page-objects/page.js b/test/e2e/page-objects/page.js index ece926ad..47d46a16 100644 --- a/test/e2e/page-objects/page.js +++ b/test/e2e/page-objects/page.js @@ -4,52 +4,78 @@ const config = require('../helpers/config'); const webdriver = require('selenium-webdriver'); const By = webdriver.By; const until = webdriver.until; +const fs = require('fs-extra'); -module.exports = driver => ({ +module.exports = (driver, ...extras) => Object.assign({ driver, + elements: {}, - element(key) { - return this.driver.findElement(By.css(this.elements[key] || key)); + async element(key) { + return await this.driver.findElement(By.css(this.elements[key] || key)); }, - navigate(path) { - this.driver.navigate().to(config.baseUrl + (path || this.url)); - return this.waitUntilVisible(); - }, + async waitUntilVisible(selector) { + // This is left here to ease debugging + // await this.sleep(2000); + // await this.takeScreenshot('image.png'); + // console.log(await this.source()); - waitUntilVisible() { - let selector = this.elements[this.elementToWaitFor]; - if (!selector && this.url) { - selector = 'body.page--' + (this.url.substring(1).replace(/\//g, '--') || 'home'); + const sel = selector || this.elements[this.elementToWaitFor] || 'body'; + await this.driver.wait(until.elementLocated(By.css(sel)), 10000); + + if (this.url) { + await this.ensureUrl(); } - return selector ? this.driver.wait(until.elementLocated(By.css(selector))) : this.driver.sleep(1000); }, - submit() { - return this.element('submitButton').click(); + async link(key) { + const elem = await this.element(key); + return await elem.getAttribute('href'); }, - click(key) { - return this.element(key).click(); + async submit() { + const submitButton = await this.element('submitButton'); + await submitButton.click(); }, - getText(key) { - return this.element(key).getText(); + async click(key) { + const elem = await this.element(key); + await elem.click(); }, - getValue(key) { - return this.element(key).getAttribute('value'); + async getText(key) { + const elem = await this.element(key); + return await elem.getText(); }, - setValue(key, value) { - return this.element(key).sendKeys(value); + async getValue(key) { + const elem = await this.element(key); + return await elem.getAttribute('value'); }, - containsText(str) { - // let text = await driver.findElement({ css: 'body' }).getText(); - return this.driver.executeScript(` + async setValue(key, value) { + const elem = await this.element(key); + await elem.sendKeys(value); + }, + + async containsText(str) { + return await this.driver.executeScript(` return (document.documentElement.textContent || document.documentElement.innerText).indexOf('${str}') > -1; `); + }, + + async source() { + return await this.driver.getPageSource(); + }, + + async takeScreenshot(destPath) { + const pngData = await this.driver.takeScreenshot(); + const buf = new Buffer(pngData, 'base64'); + await fs.writeFile(destPath, buf); + }, + + async sleep(ms) { + await this.driver.sleep(ms); } -}); +}, ...extras); diff --git a/test/e2e/page-objects/subscription.js b/test/e2e/page-objects/subscription.js index 20b56f6f..b80594f8 100644 --- a/test/e2e/page-objects/subscription.js +++ b/test/e2e/page-objects/subscription.js @@ -1,84 +1,94 @@ 'use strict'; const config = require('../helpers/config'); -const page = require('./page'); +const webBase = require('./web'); +const mailBase = require('./mail'); -const web = { - enterEmail(value) { - this.element('emailInput').clear(); - return this.element('emailInput').sendKeys(value); - } +module.exports = (driver, 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); + + return { + webSubscribe: web({ + url: `/subscription/${list.cid}`, + elementToWaitFor: 'form', + elements: { + form: `form[action="/subscription/${list.cid}/subscribe"]`, + emailInput: '#main-form input[name="email"]', + submitButton: 'a[href="#submit"]' + } + }), + + webConfirmSubscriptionNotice: web({ + url: `/subscription/${list.cid}/confirm-subscription-notice`, + elementToWaitFor: 'homepageButton', + elements: { + homepageButton: `a[href="${config.settings['default-homepage']}"]` + } + }), + + mailConfirmSubscription: mail({ + elementToWaitFor: 'confirmLink', + elements: { + confirmLink: `a[href^="${config.settings['service-url']}subscription/confirm/subscribe/"]` + } + }), + + webSubscribedNotice: web({ + url: `/subscription/${list.cid}/subscribed-notice`, + elementToWaitFor: 'homepageButton', + elements: { + homepageButton: `a[href="${config.settings['default-homepage']}"]` + } + }), + + mailSubscriptionConfirmed: mail({ + elementToWaitFor: 'unsubscribeLink', + elements: { + unsubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/unsubscribe/"]`, + manageLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}/manage/"]` + } + }), + +/* + webUnsubscribe: web({ // FIXME + elementToWaitFor: 'submitButton', + elements: { + submitButton: 'a[href="#submit"]' + } + }), +*/ + + webUnsubscribedNotice: web({ + url: `/subscription/${list.cid}/unsubscribed-notice`, + elementToWaitFor: 'homepageButton', + elements: { + homepageButton: `a[href="${config.settings['default-homepage']}"]` + } + }), + + mailUnsubscriptionConfirmed: mail({ + elementToWaitFor: 'resubscribeLink', + elements: { + 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']}"]` + } + }), +*/ + }; }; - -const mail = { - navigate(address) { - this.driver.sleep(100); - this.driver.navigate().to(`http://localhost:${config.app.testserver.mailboxserverport}/${address}`); - return this.waitUntilVisible(); - } -}; - -module.exports = (driver, list) => ({ - - webSubscribe: Object.assign(page(driver), web, { - url: `/subscription/${list.cid}`, - elementToWaitFor: 'form', - elements: { - form: `form[action="/subscription/${list.cid}/subscribe"]`, - emailInput: '#main-form input[name="email"]', - submitButton: 'a[href="#submit"]' - } - }), - - webConfirmSubscriptionNotice: Object.assign(page(driver), web, { - url: `/subscription/${list.cid}/confirm-notice`, - elementToWaitFor: 'homepageButton', - elements: { - homepageButton: `a[href="${config.settings['default-homepage']}"]` - } - }), - - mailConfirmSubscription: Object.assign(page(driver), mail, { - elementToWaitFor: 'confirmLink', - elements: { - confirmLink: `a[href^="${config.settings['service-url']}subscription/subscribe/"]` - } - }), - - webSubscribedNotice: Object.assign(page(driver), web, { - elementToWaitFor: 'homepageButton', - elements: { - homepageButton: 'a[href^="https://mailtrain.org"]' - } - }), - - mailSubscriptionConfirmed: Object.assign(page(driver), mail, { - elementToWaitFor: 'unsubscribeLink', - elements: { - unsubscribeLink: 'a[href*="/unsubscribe/"]', - manageLink: 'a[href*="/manage/"]' - } - }), - - webUnsubscribe: Object.assign(page(driver), web, { - elementToWaitFor: 'submitButton', - elements: { - submitButton: 'a[href="#submit"]' - } - }), - - webUnsubscribedNotice: Object.assign(page(driver), web, { - elementToWaitFor: 'homepageButton', - elements: { - homepageButton: 'a[href^="https://mailtrain.org"]' - } - }), - - mailUnsubscriptionConfirmed: Object.assign(page(driver), mail, { - elementToWaitFor: 'resubscribeLink', - elements: { - resubscribeLink: `a[href^="${config.settings['service-url']}subscription/${list.cid}"]` - } - }) - -}); diff --git a/test/e2e/page-objects/users.js b/test/e2e/page-objects/users.js index 27f7d82b..b0b8fe63 100644 --- a/test/e2e/page-objects/users.js +++ b/test/e2e/page-objects/users.js @@ -1,6 +1,6 @@ 'use strict'; -const page = require('./page'); +const page = require('./web'); module.exports = driver => ({ diff --git a/test/e2e/page-objects/web.js b/test/e2e/page-objects/web.js new file mode 100644 index 00000000..a0281c28 --- /dev/null +++ b/test/e2e/page-objects/web.js @@ -0,0 +1,44 @@ +'use strict'; + +const config = require('../helpers/config'); +const By = require('selenium-webdriver').By; +const url = require('url'); + +const page = require('./page'); + +module.exports = (driver, ...extras) => page(driver, { + + async navigate(path) { + await this.driver.navigate().to(config.baseUrl + (path || this.url)); + await this.waitUntilVisible(); + }, + + async ensureUrl(path) { + 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}`) { + throw new Error(`Unexpected URL. Expecting ${config.baseUrl}${this.url} got ${currentUrl.protocol}//${currentUrl.host}/${currentUrl.pathname}`); + } + } + }, + + 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)')); + return await elem.getText(); + }, + + async clearFlash() { + await this.driver.executeScript(` + var elements = document.getElementsByClassName('alert'); + while(elements.length > 0){ + elements[0].parentNode.removeChild(elements[0]); + } + `); + } +}, ...extras); diff --git a/test/e2e/tests/login.js b/test/e2e/tests/login.js index 238b5def..f06d8d23 100644 --- a/test/e2e/tests/login.js +++ b/test/e2e/tests/login.js @@ -11,8 +11,6 @@ const { } = require('../page-objects/users')(driver); describe('login', function() { - this.timeout(10000); - before(() => driver.manage().deleteAllCookies()); it('can access home page', async () => { diff --git a/test/e2e/tests/subscription-uc.js b/test/e2e/tests/subscription-uc.js new file mode 100644 index 00000000..953b5cf4 --- /dev/null +++ b/test/e2e/tests/subscription-uc.js @@ -0,0 +1,106 @@ +'use strict'; + +const config = require('../helpers/config'); +const { useCase, step } = 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); + +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 () => { + await page.webSubscribe.navigate(); + }); + + await step('User submits a valid email', async () => { + await page.webSubscribe.enterEmail(testUserEmail); + await page.webSubscribe.submit(); + }); + + 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('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 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'); + }); + + return subscription; +} + +describe('Subscription use-cases', function() { + before(() => driver.manage().deleteAllCookies()); + + useCase('Subscription to a public list (main scenario)', async () => { + await subscribe(generateEmail()); + }); + + useCase('Subscription to a public list (invalid email)', async () => { + await step('User navigates to list subscribe page', async () => { + await page.webSubscribe.navigate(); + }); + + await step('User submits an invalid email', async () => { + await page.webSubscribe.enterEmail('foo@bar.nope'); + await page.webSubscribe.submit(); + }); + + 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()); + + await step('User clicks the unsubscribe button', async () => { + await page.mailSubscriptionConfirmed.click('unsubscribeLink'); + }); + + 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 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 () => { + await page.mailSubscriptionConfirmed.click('manageLink'); + }); + + await step('System show a notice that confirms unsubscription', async () => { + await page.webManage.waitUntilVisible(); + }); + }); +*/ + after(() => driver.quit()); +}); diff --git a/test/e2e/tests/subscription.js b/test/e2e/tests/subscription.js index 2e694aa8..cee3cece 100644 --- a/test/e2e/tests/subscription.js +++ b/test/e2e/tests/subscription.js @@ -5,7 +5,7 @@ const shortid = require('shortid'); const expect = require('chai').expect; const driver = require('../helpers/driver'); -const page = require('../page-objects/page')(driver); +const page = require('../page-objects/web')(driver); const flash = require('../page-objects/flash')(driver); const {