diff --git a/.gitignore b/.gitignore index f06250660..1a010882b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /composer.phar .DS_Store /.eslintcache +/tests/selenium/log diff --git a/package.json b/package.json index aa8eec7bc..4848b045c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,9 @@ "private": true, "scripts": { "test": "grunt test && npm run doc && dev-scripts/svg_check.sh", - "doc": "jsdoc -c jsdoc.json" + "doc": "jsdoc -c jsdoc.json", + "selenium-test-cucumber": "wdio tests/selenium/wdio.conf.cucumber.js", + "selenium-test": "wdio tests/selenium/wdio.conf.js" }, "dependencies": {}, "devDependencies": { @@ -15,8 +17,13 @@ "grunt-notify": "0.4.5", "grunt-stylelint": "0.10.1", "jsdoc": "3.5.5", + "mwbot": "1.0.10", "pre-commit": "1.2.2", "stylelint-config-wikimedia": "0.5.0", - "svgo": "0.7.2" + "svgo": "0.7.2", + "wdio-cucumber-framework": "1.1.1", + "wdio-mediawiki": "0.2.0", + "wdio-spec-reporter": "0.1.4", + "webdriverio": "4.13.1" } } diff --git a/tests/selenium/.eslintrc.json b/tests/selenium/.eslintrc.json new file mode 100644 index 000000000..21fbae785 --- /dev/null +++ b/tests/selenium/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "root": true, + "extends": [ + "wikimedia/server" + ], + "env": { + "mocha": true + }, + "globals": { + "browser": false, + "$": "readonly", + "mw": false + }, + "rules": { + "camelcase": "off", + "no-restricted-syntax": "off" + } +} diff --git a/tests/selenium/README.md b/tests/selenium/README.md new file mode 100644 index 000000000..5c7d7c1b3 --- /dev/null +++ b/tests/selenium/README.md @@ -0,0 +1,42 @@ +# Selenium tests + +Please see tests/selenium/README.md file in mediawiki/core repository, usually at mediawiki/vagrant/mediawiki folder. + +## Setup + +Set up MediaWiki-Vagrant: + + cd mediawiki/vagrant + vagrant up + vagrant roles enable minerva + vagrant provision + cd mediawiki + npm install + +## Start Chromedriver and run all tests + +Run both mediawiki/core and extension tests from mediawiki/core repository (usually at mediawiki/vagrant/mediawiki folder): + + npm run selenium + +## Start Chromedriver + +To run only some tests, you first have to start Chromedriver in one terminal tab (or window): + + chromedriver --url-base=wd/hub --port=4444 + +## Run test(s) from one file + +Then, in another terminal tab (or window) run this from mediawiki/core repository (usually at mediawiki/vagrant/mediawiki folder): + + npm run selenium-test -- --spec tests/selenium/specs/FILE-NAME.js + +`wdio` is a dependency of mediawiki/core that you have installed with `npm install`. + +## Run specific test(s) + +To run only test(s) which name contains string TEST-NAME, run this from mediawiki/core repository (usually at mediawiki/vagrant/mediawiki folder): + + ./node_modules/.bin/wdio tests/selenium/wdio.conf.js --spec extensions/EXTENSION-NAME/tests/selenium/specs/FILE-NAME.js --mochaOpts.grep TEST-NAME + +Make sure Chromedriver is running when executing the above command. diff --git a/tests/selenium/features/category.feature b/tests/selenium/features/category.feature new file mode 100644 index 000000000..9916cea46 --- /dev/null +++ b/tests/selenium/features/category.feature @@ -0,0 +1,10 @@ +Feature: Categories + + Scenario: I can view categories + Given I am in a wiki that has categories + And I am using the mobile site + And I am in beta mode + And I am on the "Selenium categories test page" page + When I click on the category button + Then I should see the categories overlay + And I should see a list of categories diff --git a/tests/selenium/features/step_definitions/category_steps.js b/tests/selenium/features/step_definitions/category_steps.js new file mode 100644 index 000000000..ee9e479e1 --- /dev/null +++ b/tests/selenium/features/step_definitions/category_steps.js @@ -0,0 +1,24 @@ +const assert = require( 'assert' ), + { ArticlePage } = require( './../support/world' ); + +const iClickOnTheCategoryButton = () => { + ArticlePage.category_element.waitForExist(); + ArticlePage.category_element.click(); +}; + +const iShouldSeeTheCategoriesOverlay = () => { + ArticlePage.overlay_heading_element.waitForExist(); + assert.strictEqual( ArticlePage.overlay_heading_element.getText(), + 'Categories' ); +}; + +const iShouldSeeAListOfCategories = () => { + const el = ArticlePage.overlay_category_topic_item_element.waitForVisible(); + assert.strictEqual( el, true ); +}; + +module.exports = { + iClickOnTheCategoryButton, + iShouldSeeTheCategoriesOverlay, + iShouldSeeAListOfCategories +}; diff --git a/tests/selenium/features/step_definitions/common_steps.js b/tests/selenium/features/step_definitions/common_steps.js new file mode 100644 index 000000000..b41fb1cf7 --- /dev/null +++ b/tests/selenium/features/step_definitions/common_steps.js @@ -0,0 +1,31 @@ +const assert = require( 'assert' ), + { ArticlePage, UserLoginPage } = require( '../support/world' ); + +const iAmUsingTheMobileSite = () => { + ArticlePage.setMobileMode(); +}; + +const iAmInBetaMode = () => { + ArticlePage.setBetaMode(); +}; + +const iAmOnPage = ( article ) => { + ArticlePage.open( article ); +}; + +const iAmLoggedIn = () => { + UserLoginPage.open(); + UserLoginPage.loginAdmin(); + assert.strictEqual( ArticlePage.is_authenticated_element.isExisting(), true ); +}; + +const iAmLoggedIntoTheMobileWebsite = () => { + iAmUsingTheMobileSite(); + iAmLoggedIn(); +}; + +module.exports = { + iAmLoggedIntoTheMobileWebsite, + iAmUsingTheMobileSite, + iAmLoggedIn, iAmOnPage, iAmInBetaMode +}; diff --git a/tests/selenium/features/step_definitions/create_page_api_steps.js b/tests/selenium/features/step_definitions/create_page_api_steps.js new file mode 100644 index 000000000..8e035e104 --- /dev/null +++ b/tests/selenium/features/step_definitions/create_page_api_steps.js @@ -0,0 +1,52 @@ +const { api } = require( '../support/world' ), + Api = require( 'wdio-mediawiki/Api' ); + +const login = () => { + return api.loginGetEditToken( { + username: browser.options.username, + password: browser.options.password, + apiUrl: `${browser.options.baseUrl}/api.php` + } ); +}; + +const waitForPropagation = ( timeMs ) => { + // wait 2 seconds so the change can propogate. + const d = new Date(); + browser.waitUntil( () => new Date() - d > timeMs ); +}; + +const iAmInAWikiThatHasCategories = ( title ) => { + const msg = 'This page is used by Selenium to test category related features.', + summary = 'edit by selenium test', + wikitext = ` + ${msg} + + [[Category:Test category]] + [[Category:Selenium artifacts]] + [[Category:Selenium hidden category]] + `; + + login().then( () => api.batch( [ + [ 'create', 'Category:Selenium artifacts', msg, summary ], + [ 'create', 'Category:Test category', msg, summary ], + [ 'create', 'Category:Selenium hidden category', '__HIDDENCAT__', summary ] + ] ) ) + .catch( ( err ) => { + if ( err.code === 'articleexists' ) { + return; + } + throw err; + } ); + + // A pause is necessary to let the categories register with database before trying to use + // them in an article + waitForPropagation( 5000 ); + Api.edit( title, wikitext ); + // categories are handled by a JobRunner so need extra time to appear via API calls! + waitForPropagation( 5000 ); +}; + +module.exports = { + waitForPropagation, + iAmInAWikiThatHasCategories +}; diff --git a/tests/selenium/features/step_definitions/history_steps.js b/tests/selenium/features/step_definitions/history_steps.js new file mode 100644 index 000000000..00551a0d4 --- /dev/null +++ b/tests/selenium/features/step_definitions/history_steps.js @@ -0,0 +1,12 @@ +const assert = require( 'assert' ), + { ArticlePage, SpecialHistoryPage } = require( '../support/world' ); + +const iClickOnTheHistoryLinkInTheLastModifiedBar = () => { + ArticlePage.last_modified_bar_history_link_element.waitForVisible(); + ArticlePage.last_modified_bar_history_link_element.click(); + assert.strictEqual( SpecialHistoryPage.side_list_element.isVisible(), true ); +}; + +module.exports = { + iClickOnTheHistoryLinkInTheLastModifiedBar +}; diff --git a/tests/selenium/features/step_definitions/index.js b/tests/selenium/features/step_definitions/index.js new file mode 100644 index 000000000..2270d28d3 --- /dev/null +++ b/tests/selenium/features/step_definitions/index.js @@ -0,0 +1,41 @@ +const { defineSupportCode } = require( 'cucumber' ), + { iClickOnTheCategoryButton, + iShouldSeeTheCategoriesOverlay, iShouldSeeAListOfCategories + } = require( './category_steps' ), + { iAmInAWikiThatHasCategories } = require( './create_page_api_steps' ), + { + iAmUsingTheMobileSite, + iAmLoggedIntoTheMobileWebsite, + iAmOnPage, iAmInBetaMode + } = require( './common_steps' ), + { + iClickOnTheHistoryLinkInTheLastModifiedBar + } = require( './history_steps' ); + +defineSupportCode( function ( { Then, When, Given } ) { + + // common steps + Given( /^I am using the mobile site$/, iAmUsingTheMobileSite ); + + Given( /^I am in beta mode$/, iAmInBetaMode ); + + Given( /^I am on the "(.+)" page$/, iAmOnPage ); + + Given( /^I am logged into the mobile website$/, iAmLoggedIntoTheMobileWebsite ); + + // Page steps + Given( /^I am in a wiki that has categories$/, () => { + iAmInAWikiThatHasCategories( 'Selenium categories test page' ); + } ); + + // history steps + When( /^I click on the history link in the last modified bar$/, + iClickOnTheHistoryLinkInTheLastModifiedBar ); + + // Category steps + When( /^I click on the category button$/, iClickOnTheCategoryButton ); + + Then( /^I should see the categories overlay$/, iShouldSeeTheCategoriesOverlay ); + + Then( /^I should see a list of categories$/, iShouldSeeAListOfCategories ); +} ); diff --git a/tests/selenium/features/support/hooks.js b/tests/selenium/features/support/hooks.js new file mode 100644 index 000000000..5affbc5cd --- /dev/null +++ b/tests/selenium/features/support/hooks.js @@ -0,0 +1,22 @@ +/** + * Hooks + * + * Hooks are used for setup and teardown of the environment before and after each scenario. + * It's preferable to use tags to invoke hooks rather than using the generic 'Before' and 'After' + * events, which execute before and after all scenario. + * https://github.com/cucumber/cucumber-js/blob/master/docs/support_files/hooks.md + */ + +const { After, Before } = require( 'cucumber' ); + +Before( function () { + // This hook will be executed before ALL scenarios +} ); + +After( function () { + // This hook will be executed after ALL scenarios +} ); + +Before( { tags: '@foo' }, function () { + // This hook will be executed before scenarios tagged with @foo +} ); diff --git a/tests/selenium/features/support/pages/article_page.js b/tests/selenium/features/support/pages/article_page.js new file mode 100644 index 000000000..4518489ea --- /dev/null +++ b/tests/selenium/features/support/pages/article_page.js @@ -0,0 +1,21 @@ +/** + * Represents a generic article page + * + * @extends MinervaPage + * @example + * https://en.m.wikipedia.org/wiki/Barack_Obama + */ + +const MinervaPage = require( './minerva_page' ); + +class ArticlePage extends MinervaPage { + + get category_element() { return $( '.category-button' ); } + get overlay_heading_element() { return $( '.overlay-title h2' ); } + get overlay_category_topic_item_element() { return $( '.topic-title-list li' ); } + get is_authenticated_element() { return $( 'body.is-authenticated' ); } + get last_modified_bar_history_link_element() { return $( '.last-modifier-tagline a[href*=\'Special:History\']' ); } + +} + +module.exports = new ArticlePage(); diff --git a/tests/selenium/features/support/pages/minerva_page.js b/tests/selenium/features/support/pages/minerva_page.js new file mode 100644 index 000000000..8feeb9ab6 --- /dev/null +++ b/tests/selenium/features/support/pages/minerva_page.js @@ -0,0 +1,67 @@ +/** + * Represents a page the can be presented in desktop + * or mobile mode (requires mobilefrontend), and has + * features like public 'beta' mode (requires mobilefrontend). + * + * @extends Page + * @example + * https://en.m.wikipedia.org/wiki/Barack_Obama + */ + +const { Page } = require( './mw_core_pages' ); + +class MinervaPage extends Page { + + get title() { return browser.getTitle(); } + + /** + * Opens a page if it isn't already open. + * @param {string} path + */ + open( path = 'Main_Page' ) { + const currentPage = browser.getUrl(), + newPage = browser.options.baseUrl + '/index.php?title=' + path; + if ( currentPage !== newPage ) { + browser.url( newPage ); + } + } + + /** + * Ensure browser is opened on a MediaWiki page, and set a specified + * cookie for that domain. + * @param {string} name - name of the cookie + * @param {string} value - value of the cookie + */ + setCookie( name, value ) { + const currentPage = browser.getUrl(); + let cookie; + if ( !currentPage.includes( browser.options.baseUrl ) ) { + this.open(); + } + + cookie = browser.getCookie( name ); + + if ( !cookie || cookie.value !== value ) { + browser.setCookie( { + name: name, + value: value } ); + } + } + + /** + * Set the mobile cookie + */ + setMobileMode() { + this.setCookie( 'mf_useformat', 'true' ); + } + + /** + * Set the beta cookie + */ + setBetaMode() { + this.setCookie( 'optin', 'beta' ); + } + +} + +module.exports = MinervaPage; diff --git a/tests/selenium/features/support/pages/minerva_pages.js b/tests/selenium/features/support/pages/minerva_pages.js new file mode 100644 index 000000000..4fc39edd9 --- /dev/null +++ b/tests/selenium/features/support/pages/minerva_pages.js @@ -0,0 +1,7 @@ +/** + * A list of all custom Minerva pageObjects. + * To simplify imports in world.js. + */ +module.exports = { + ArticlePage: require( './article_page' ) +}; diff --git a/tests/selenium/features/support/pages/mw_core_pages.js b/tests/selenium/features/support/pages/mw_core_pages.js new file mode 100644 index 000000000..c33f858da --- /dev/null +++ b/tests/selenium/features/support/pages/mw_core_pages.js @@ -0,0 +1,9 @@ +/** + * A list of all MediaWiki core pageObjects. + * To simplify imports in world.js. + */ +module.exports = { + // Page is a constructor, all other pageObjects are instances. + Page: require( '../../../../../../../tests/selenium/pageobjects/page.js' ), + UserLoginPage: require( '../../../../../../../tests/selenium/pageobjects/userlogin.page.js' ) +}; diff --git a/tests/selenium/features/support/world.js b/tests/selenium/features/support/world.js new file mode 100644 index 000000000..30266eab2 --- /dev/null +++ b/tests/selenium/features/support/world.js @@ -0,0 +1,30 @@ +/** + * World + * + * World is a function that is bound as `this` to each step of a scenario. + * It is reset for each scenario. + * https://github.com/cucumber/cucumber-js/blob/master/docs/support_files/world.md + * + * Contrary to Cucumber.js best practices, this `MinervaWorld` is not being + * bound to scenarios with the `setWorldConstructor` like this: + * + * setWorldConstructor(MinervaWorld); + * + * Instead, it acts as a simple function that encapsulates all dependencies, + * and is exported so that it can be imported into each step definition file, + * allowing us to use the dependencies across scenarios. + */ + +const MwBot = require( 'mwbot' ), + mwCorePages = require( '../support/pages/mw_core_pages' ), + minervaPages = require( '../support/pages/minerva_pages' ); + +function MinervaWorld() { + /* dependencies */ + this.api = new MwBot(); + /* pageObjects */ + Object.assign( this, mwCorePages ); + Object.assign( this, minervaPages ); +} + +module.exports = new MinervaWorld(); diff --git a/tests/selenium/specs/category.js b/tests/selenium/specs/category.js new file mode 100644 index 000000000..64faf252d --- /dev/null +++ b/tests/selenium/specs/category.js @@ -0,0 +1,46 @@ +const { iClickOnTheCategoryButton, + iShouldSeeTheCategoriesOverlay, + iShouldSeeAListOfCategories + } = require( '../features/step_definitions/category_steps' ), + { + iAmInAWikiThatHasCategories + } = require( '../features/step_definitions/create_page_api_steps' ), + { + iAmUsingTheMobileSite, + iAmOnPage, iAmInBetaMode + } = require( '../features/step_definitions/common_steps' ); + +// Feature: Categories +describe( 'Categories', function () { + // Scenario: I can view categories + it( 'I can view categories', function () { + const title = 'Selenium categories test page'; + // Given I am in a wiki that has categories + iAmInAWikiThatHasCategories( title ); + + // And I am using the mobile site + iAmUsingTheMobileSite(); + + // And I am in beta mode + iAmInBetaMode(); + + // And I am on the "Selenium categories test page" page + iAmOnPage( title ); + + // When I click on the category button + iClickOnTheCategoryButton(); + + // Then I should see the categories overlay + iShouldSeeTheCategoriesOverlay(); + + // FIXME: This check is partially skipped as there is no way to lower $wgJobRunRate + // See: T199939#5095838 + try { + iShouldSeeAListOfCategories(); + } catch ( e ) { + // pass. + // eslint-disable-next-line no-console + console.warn( 'Unable to check the list of the categories. Is wgJobRunRate set correctly?' ); + } + } ); +} ); diff --git a/tests/selenium/wdio.conf.cucumber.js b/tests/selenium/wdio.conf.cucumber.js new file mode 100644 index 000000000..80db4d803 --- /dev/null +++ b/tests/selenium/wdio.conf.cucumber.js @@ -0,0 +1,14 @@ +const { config } = require( './wdio.conf' ); + +config.specs = [ __dirname + '/features/*.feature' ]; +config.framework = 'cucumber'; +config.cucumberOpts = { + require: [ + './tests/selenium/features/support/*.js', + './tests/selenium/features/step_definitions/index.js' + // search a (sub)folder for JS files with a wildcard + // works since version 1.1 of the wdio-cucumber-framework + // './src/**/*.js', + ] +}; +exports.config = config; diff --git a/tests/selenium/wdio.conf.js b/tests/selenium/wdio.conf.js new file mode 100644 index 000000000..89a4baff0 --- /dev/null +++ b/tests/selenium/wdio.conf.js @@ -0,0 +1,95 @@ +/** + * See also: http://webdriver.io/guide/testrunner/configurationfile.html + */ +const fs = require( 'fs' ), + saveScreenshot = require( 'wdio-mediawiki' ).saveScreenshot; + +exports.config = { + // ====== + // Custom WDIO config specific to MediaWiki + // ====== + // Use in a test as `browser.options.`. + // Defaults are for convenience with MediaWiki-Vagrant + + // Wiki admin + username: process.env.MEDIAWIKI_USER || 'Admin', + password: process.env.MEDIAWIKI_PASSWORD || 'vagrant', + + // Base for browser.url() and Page#openTitle() + baseUrl: ( process.env.MW_SERVER || 'http://127.0.0.1:8080' ) + ( + process.env.MW_SCRIPT_PATH || '/w' + ), + + // ================== + // Test Files + // ================== + specs: [ + __dirname + '/specs/*.js' + ], + + // ============ + // Capabilities + // ============ + capabilities: [ { + // https://sites.google.com/a/chromium.org/chromedriver/capabilities + browserName: 'chrome', + maxInstances: 1, + chromeOptions: { + // If DISPLAY is set, assume developer asked non-headless or CI with Xvfb. + // Otherwise, use --headless (added in Chrome 59) + // https://chromium.googlesource.com/chromium/src/+/59.0.3030.0/headless/README.md + args: [ + ...( process.env.DISPLAY ? [] : [ '--headless' ] ), + // Chrome sandbox does not work in Docker + ...( fs.existsSync( '/.dockerenv' ) ? [ '--no-sandbox' ] : [] ) + ] + } + } ], + + // =================== + // Test Configurations + // =================== + + // Level of verbosity: silent | verbose | command | data | result | error + logLevel: 'error', + + // Setting this enables automatic screenshots for when a browser command fails + // It is also used by afterTest for capturig failed assertions. + screenshotPath: process.env.LOG_DIR || __dirname + '/log', + + // Default timeout for each waitFor* command. + waitforTimeout: 10 * 1000, + + // See also: http://webdriver.io/guide/testrunner/reporters.html + reporters: [ 'spec' ], + + // See also: http://mochajs.org + mochaOpts: { + ui: 'bdd', + timeout: 60 * 1000 + }, + + // Make sure you have the wdio adapter package for the specific framework + // installed before running any tests. + framework: 'mocha', + + // ===== + // Hooks + // ===== + + /** + * Save a screenshot when test fails. + * + * @param {Object} test Mocha Test object + */ + afterTest: function ( test ) { + var filePath; + if ( !test.passed ) { + filePath = saveScreenshot( test.title ); + // eslint-disable-next-line no-console + console.log( '\n\tScreenshot: ' + filePath + '\n' ); + } + } +}; + +module.exports = exports;