Merge "jquery.tablesorter: Move files to their own directory"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Wed, 9 May 2018 17:42:41 +0000 (17:42 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Wed, 9 May 2018 17:42:41 +0000 (17:42 +0000)
35 files changed:
includes/user/User.php
includes/user/UserIdentity.php
includes/user/UserIdentityValue.php
package.json
resources/Resources.php
resources/src/mediawiki.messagePoster.wikitext/WikitextMessagePoster.js [new file with mode: 0644]
resources/src/mediawiki.messagePoster/MessagePoster.js [new file with mode: 0644]
resources/src/mediawiki.messagePoster/factory.js [new file with mode: 0644]
resources/src/mediawiki.messagePoster/mediawiki.messagePoster.MessagePoster.js [deleted file]
resources/src/mediawiki.messagePoster/mediawiki.messagePoster.WikitextMessagePoster.js [deleted file]
resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js [deleted file]
resources/src/mediawiki/mediawiki.js
tests/selenium/README.md
tests/selenium/pageobjects/createaccount.page.js
tests/selenium/pageobjects/delete.page.js
tests/selenium/pageobjects/edit.page.js
tests/selenium/pageobjects/history.page.js
tests/selenium/pageobjects/page.js
tests/selenium/pageobjects/preferences.page.js
tests/selenium/pageobjects/restore.page.js
tests/selenium/pageobjects/userlogin.page.js
tests/selenium/specs/page.js
tests/selenium/specs/user.js
tests/selenium/wdio-mediawiki/.eslintrc.json [new file with mode: 0644]
tests/selenium/wdio-mediawiki/Api.js [new file with mode: 0644]
tests/selenium/wdio-mediawiki/BlankPage.js [new file with mode: 0644]
tests/selenium/wdio-mediawiki/CHANGELOG.md [new file with mode: 0644]
tests/selenium/wdio-mediawiki/LICENSE [new file with mode: 0644]
tests/selenium/wdio-mediawiki/LoginPage.js [new file with mode: 0644]
tests/selenium/wdio-mediawiki/Page.js [new file with mode: 0644]
tests/selenium/wdio-mediawiki/README.md [new file with mode: 0644]
tests/selenium/wdio-mediawiki/index.js [new file with mode: 0644]
tests/selenium/wdio-mediawiki/package.json [new file with mode: 0644]
tests/selenium/wdio-mediawiki/specs/BlankPage.js [new file with mode: 0644]
tests/selenium/wdio.conf.js

index 5a5139d..e235c18 100644 (file)
@@ -5663,11 +5663,12 @@ class User implements IDBAccessObject, UserIdentity {
        /**
         * Checks if two user objects point to the same user.
         *
-        * @since 1.25
-        * @param User $user
+        * @since 1.25 ; takes a UserIdentity instead of a User since 1.32
+        * @param UserIdentity $user
         * @return bool
         */
-       public function equals( User $user ) {
+       public function equals( UserIdentity $user ) {
+               // XXX it's not clear whether central ID providers are supposed to obey this
                return $this->getName() === $user->getName();
        }
 }
index d02a678..ac9bbec 100644 (file)
@@ -54,4 +54,12 @@ interface UserIdentity {
 
        // TODO: we may want to (optionally?) provide a global ID, see CentralIdLookup.
 
+       /**
+        * @since 1.32
+        *
+        * @param UserIdentity $user
+        * @return bool
+        */
+       public function equals( UserIdentity $user );
+
 }
index 120f31f..d1fd19d 100644 (file)
@@ -82,4 +82,15 @@ class UserIdentityValue implements UserIdentity {
                return $this->actor;
        }
 
+       /**
+        * @since 1.32
+        *
+        * @param UserIdentity $user
+        * @return bool
+        */
+       public function equals( UserIdentity $user ) {
+               // XXX it's not clear whether central ID providers are supposed to obey this
+               return $this->getName() === $user->getName();
+       }
+
 }
index d6fd1b9..e9048ec 100644 (file)
     "karma-firefox-launcher": "1.0.1",
     "karma-mocha-reporter": "2.2.5",
     "karma-qunit": "2.0.1",
-    "mwbot": "1.0.10",
     "postcss-less": "1.1.5",
     "qunit": "2.5.0",
     "stylelint": "9.2.0",
     "stylelint-config-wikimedia": "0.4.3",
     "wdio-junit-reporter": "0.2.0",
+    "wdio-mediawiki": "file:tests/selenium/wdio-mediawiki",
     "wdio-mocha-framework": "0.5.8",
     "wdio-sauce-service": "0.3.1",
     "wdio-spec-reporter": "0.0.5",
index fb24c39..30a14cb 100644 (file)
@@ -1134,8 +1134,8 @@ return [
        ],
        'mediawiki.messagePoster' => [
                'scripts' => [
-                       'resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js',
-                       'resources/src/mediawiki.messagePoster/mediawiki.messagePoster.MessagePoster.js',
+                       'resources/src/mediawiki.messagePoster/factory.js',
+                       'resources/src/mediawiki.messagePoster/MessagePoster.js',
                ],
                'dependencies' => [
                        'oojs',
@@ -1146,7 +1146,7 @@ return [
        ],
        'mediawiki.messagePoster.wikitext' => [
                'scripts' => [
-                       'resources/src/mediawiki.messagePoster/mediawiki.messagePoster.WikitextMessagePoster.js',
+                       'resources/src/mediawiki.messagePoster.wikitext/WikitextMessagePoster.js',
                ],
                'dependencies' => [
                        'mediawiki.api.edit',
diff --git a/resources/src/mediawiki.messagePoster.wikitext/WikitextMessagePoster.js b/resources/src/mediawiki.messagePoster.wikitext/WikitextMessagePoster.js
new file mode 100644 (file)
index 0000000..a2dbcd4
--- /dev/null
@@ -0,0 +1,53 @@
+( function ( mw, $ ) {
+       /**
+        * This is an implementation of MessagePoster for wikitext talk pages.
+        *
+        * @class mw.messagePoster.WikitextMessagePoster
+        * @extends mw.messagePoster.MessagePoster
+        *
+        * @constructor
+        * @param {mw.Title} title Wikitext page in a talk namespace, to post to
+        * @param {mw.Api} api mw.Api object to use
+        */
+       function WikitextMessagePoster( title, api ) {
+               this.api = api;
+               this.title = title;
+       }
+
+       OO.inheritClass(
+               WikitextMessagePoster,
+               mw.messagePoster.MessagePoster
+       );
+
+       /**
+        * @inheritdoc
+        */
+       WikitextMessagePoster.prototype.post = function ( subject, body ) {
+               mw.messagePoster.WikitextMessagePoster.parent.prototype.post.call( this, subject, body );
+
+               // Add signature if needed
+               if ( body.indexOf( '~~~' ) === -1 ) {
+                       body += '\n\n~~~~';
+               }
+
+               return this.api.newSection(
+                       this.title,
+                       subject,
+                       body,
+                       { redirect: true }
+               ).then( function ( resp, jqXHR ) {
+                       if ( resp.edit.result === 'Success' ) {
+                               return $.Deferred().resolve( resp, jqXHR );
+                       } else {
+                               // mw.Api checks for response error.  Are there actually cases where the
+                               // request fails, but it's not caught there?
+                               return $.Deferred().reject( 'api-unexpected' );
+                       }
+               }, function ( code, details ) {
+                       return $.Deferred().reject( 'api-fail', code, details );
+               } ).promise();
+       };
+
+       mw.messagePoster.factory.register( 'wikitext', WikitextMessagePoster );
+       mw.messagePoster.WikitextMessagePoster = WikitextMessagePoster;
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.messagePoster/MessagePoster.js b/resources/src/mediawiki.messagePoster/MessagePoster.js
new file mode 100644 (file)
index 0000000..64642b2
--- /dev/null
@@ -0,0 +1,40 @@
+( function ( mw ) {
+       /**
+        * This is the abstract base class for MessagePoster implementations.
+        *
+        * @abstract
+        * @class
+        *
+        * @constructor
+        * @param {mw.Title} title Title to post to
+        */
+       mw.messagePoster.MessagePoster = function MwMessagePoster() {};
+
+       OO.initClass( mw.messagePoster.MessagePoster );
+
+       /**
+        * Post a message (with subject and body) to a talk page.
+        *
+        * @abstract
+        * @param {string} subject Subject/topic title.  The amount of wikitext supported is
+        *   implementation-specific. It is recommended to only use basic wikilink syntax for
+        *   maximum compatibility.
+        * @param {string} body Body, as wikitext.  Signature code will automatically be added
+        *   by MessagePosters that require one, unless the message already contains the string
+        *   ~~~.
+        * @return {jQuery.Promise} Promise completing when the post succeeds or fails.
+        *   For failure, will be rejected with three arguments:
+        *
+        *   - primaryError - Primary error code.  For a mw.Api failure,
+        *       this should be 'api-fail'.
+        *   - secondaryError - Secondary error code.  For a mw.Api failure,
+        *       this, should be mw.Api's code, e.g. 'http', 'ok-but-empty', or the error passed through
+        *       from the server.
+        *   - details - Further details about the error
+        *
+        * @localdoc
+        * The base class currently does nothing, but could be used for shared analytics or
+        * something.
+        */
+       mw.messagePoster.MessagePoster.prototype.post = function () {};
+}( mediaWiki ) );
diff --git a/resources/src/mediawiki.messagePoster/factory.js b/resources/src/mediawiki.messagePoster/factory.js
new file mode 100644 (file)
index 0000000..e20b422
--- /dev/null
@@ -0,0 +1,107 @@
+( function ( mw, $ ) {
+       /**
+        * Factory for MessagePoster objects. This provides a pluggable to way to script the action
+        * of adding a message to someone's talk page.
+        *
+        * @class mw.messagePoster.factory
+        * @singleton
+        */
+       function MessagePosterFactory() {
+               this.contentModelToClass = {};
+       }
+
+       OO.initClass( MessagePosterFactory );
+
+       // Note: This registration scheme is currently not compatible with LQT, since that doesn't
+       // have its own content model, just islqttalkpage. LQT pages will be passed to the wikitext
+       // MessagePoster.
+       /**
+        * Register a MessagePoster subclass for a given content model.
+        *
+        * @param {string} contentModel Content model of pages this MessagePoster can post to
+        * @param {Function} constructor Constructor of a MessagePoster subclass
+        */
+       MessagePosterFactory.prototype.register = function ( contentModel, constructor ) {
+               if ( this.contentModelToClass[ contentModel ] !== undefined ) {
+                       throw new Error( 'Content model "' + contentModel + '" is already registered' );
+               }
+
+               this.contentModelToClass[ contentModel ] = constructor;
+       };
+
+       /**
+        * Unregister a given content model.
+        * This is exposed for testing and should not normally be used.
+        *
+        * @param {string} contentModel Content model to unregister
+        */
+       MessagePosterFactory.prototype.unregister = function ( contentModel ) {
+               delete this.contentModelToClass[ contentModel ];
+       };
+
+       /**
+        * Create a MessagePoster for given a title.
+        *
+        * A promise for this is returned. It works by determining the content model, then loading
+        * the corresponding module (which registers the MessagePoster class), and finally constructing
+        * an object for the given title.
+        *
+        * This does not require the message and should be called as soon as possible, so that the
+        * API and ResourceLoader requests run in the background.
+        *
+        * @param {mw.Title} title Title that will be posted to
+        * @param {string} [apiUrl] api.php URL if the title is on another wiki
+        * @return {jQuery.Promise} Promise resolving to a mw.messagePoster.MessagePoster.
+        *   For failure, rejected with up to three arguments:
+        *
+        *   - errorCode Error code string
+        *   - error Error explanation
+        *   - details Further error details
+        */
+       MessagePosterFactory.prototype.create = function ( title, apiUrl ) {
+               var factory = this,
+                       api = apiUrl ? new mw.ForeignApi( apiUrl ) : new mw.Api();
+
+               return api.get( {
+                       formatversion: 2,
+                       action: 'query',
+                       prop: 'info',
+                       titles: title.getPrefixedDb()
+               } ).then( function ( data ) {
+                       var contentModel, moduleName, page = data.query.pages[ 0 ];
+                       if ( !page ) {
+                               return $.Deferred().reject( 'unexpected-response', 'Unexpected API response' );
+                       }
+                       contentModel = page.contentmodel;
+                       moduleName = 'mediawiki.messagePoster.' + contentModel;
+                       return mw.loader.using( moduleName ).then( function () {
+                               return factory.createForContentModel(
+                                       contentModel,
+                                       title,
+                                       api
+                               );
+                       }, function () {
+                               return $.Deferred().reject( 'failed-to-load-module', 'Failed to load "' + moduleName + '"' );
+                       } );
+               }, function ( error, details ) {
+                       return $.Deferred().reject( 'content-model-query-failed', error, details );
+               } );
+       };
+
+       /**
+        * Creates a MessagePoster instance, given a title and content model
+        *
+        * @private
+        * @param {string} contentModel Content model of title
+        * @param {mw.Title} title Title being posted to
+        * @param {mw.Api} api mw.Api instance that the instance should use
+        * @return {mw.messagePoster.MessagePoster}
+        */
+       MessagePosterFactory.prototype.createForContentModel = function ( contentModel, title, api ) {
+               return new this.contentModelToClass[ contentModel ]( title, api );
+       };
+
+       mw.messagePoster = {
+               factory: new MessagePosterFactory()
+       };
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.MessagePoster.js b/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.MessagePoster.js
deleted file mode 100644 (file)
index 64642b2..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-( function ( mw ) {
-       /**
-        * This is the abstract base class for MessagePoster implementations.
-        *
-        * @abstract
-        * @class
-        *
-        * @constructor
-        * @param {mw.Title} title Title to post to
-        */
-       mw.messagePoster.MessagePoster = function MwMessagePoster() {};
-
-       OO.initClass( mw.messagePoster.MessagePoster );
-
-       /**
-        * Post a message (with subject and body) to a talk page.
-        *
-        * @abstract
-        * @param {string} subject Subject/topic title.  The amount of wikitext supported is
-        *   implementation-specific. It is recommended to only use basic wikilink syntax for
-        *   maximum compatibility.
-        * @param {string} body Body, as wikitext.  Signature code will automatically be added
-        *   by MessagePosters that require one, unless the message already contains the string
-        *   ~~~.
-        * @return {jQuery.Promise} Promise completing when the post succeeds or fails.
-        *   For failure, will be rejected with three arguments:
-        *
-        *   - primaryError - Primary error code.  For a mw.Api failure,
-        *       this should be 'api-fail'.
-        *   - secondaryError - Secondary error code.  For a mw.Api failure,
-        *       this, should be mw.Api's code, e.g. 'http', 'ok-but-empty', or the error passed through
-        *       from the server.
-        *   - details - Further details about the error
-        *
-        * @localdoc
-        * The base class currently does nothing, but could be used for shared analytics or
-        * something.
-        */
-       mw.messagePoster.MessagePoster.prototype.post = function () {};
-}( mediaWiki ) );
diff --git a/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.WikitextMessagePoster.js b/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.WikitextMessagePoster.js
deleted file mode 100644 (file)
index a2dbcd4..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-( function ( mw, $ ) {
-       /**
-        * This is an implementation of MessagePoster for wikitext talk pages.
-        *
-        * @class mw.messagePoster.WikitextMessagePoster
-        * @extends mw.messagePoster.MessagePoster
-        *
-        * @constructor
-        * @param {mw.Title} title Wikitext page in a talk namespace, to post to
-        * @param {mw.Api} api mw.Api object to use
-        */
-       function WikitextMessagePoster( title, api ) {
-               this.api = api;
-               this.title = title;
-       }
-
-       OO.inheritClass(
-               WikitextMessagePoster,
-               mw.messagePoster.MessagePoster
-       );
-
-       /**
-        * @inheritdoc
-        */
-       WikitextMessagePoster.prototype.post = function ( subject, body ) {
-               mw.messagePoster.WikitextMessagePoster.parent.prototype.post.call( this, subject, body );
-
-               // Add signature if needed
-               if ( body.indexOf( '~~~' ) === -1 ) {
-                       body += '\n\n~~~~';
-               }
-
-               return this.api.newSection(
-                       this.title,
-                       subject,
-                       body,
-                       { redirect: true }
-               ).then( function ( resp, jqXHR ) {
-                       if ( resp.edit.result === 'Success' ) {
-                               return $.Deferred().resolve( resp, jqXHR );
-                       } else {
-                               // mw.Api checks for response error.  Are there actually cases where the
-                               // request fails, but it's not caught there?
-                               return $.Deferred().reject( 'api-unexpected' );
-                       }
-               }, function ( code, details ) {
-                       return $.Deferred().reject( 'api-fail', code, details );
-               } ).promise();
-       };
-
-       mw.messagePoster.factory.register( 'wikitext', WikitextMessagePoster );
-       mw.messagePoster.WikitextMessagePoster = WikitextMessagePoster;
-}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js b/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js
deleted file mode 100644 (file)
index e20b422..0000000
+++ /dev/null
@@ -1,107 +0,0 @@
-( function ( mw, $ ) {
-       /**
-        * Factory for MessagePoster objects. This provides a pluggable to way to script the action
-        * of adding a message to someone's talk page.
-        *
-        * @class mw.messagePoster.factory
-        * @singleton
-        */
-       function MessagePosterFactory() {
-               this.contentModelToClass = {};
-       }
-
-       OO.initClass( MessagePosterFactory );
-
-       // Note: This registration scheme is currently not compatible with LQT, since that doesn't
-       // have its own content model, just islqttalkpage. LQT pages will be passed to the wikitext
-       // MessagePoster.
-       /**
-        * Register a MessagePoster subclass for a given content model.
-        *
-        * @param {string} contentModel Content model of pages this MessagePoster can post to
-        * @param {Function} constructor Constructor of a MessagePoster subclass
-        */
-       MessagePosterFactory.prototype.register = function ( contentModel, constructor ) {
-               if ( this.contentModelToClass[ contentModel ] !== undefined ) {
-                       throw new Error( 'Content model "' + contentModel + '" is already registered' );
-               }
-
-               this.contentModelToClass[ contentModel ] = constructor;
-       };
-
-       /**
-        * Unregister a given content model.
-        * This is exposed for testing and should not normally be used.
-        *
-        * @param {string} contentModel Content model to unregister
-        */
-       MessagePosterFactory.prototype.unregister = function ( contentModel ) {
-               delete this.contentModelToClass[ contentModel ];
-       };
-
-       /**
-        * Create a MessagePoster for given a title.
-        *
-        * A promise for this is returned. It works by determining the content model, then loading
-        * the corresponding module (which registers the MessagePoster class), and finally constructing
-        * an object for the given title.
-        *
-        * This does not require the message and should be called as soon as possible, so that the
-        * API and ResourceLoader requests run in the background.
-        *
-        * @param {mw.Title} title Title that will be posted to
-        * @param {string} [apiUrl] api.php URL if the title is on another wiki
-        * @return {jQuery.Promise} Promise resolving to a mw.messagePoster.MessagePoster.
-        *   For failure, rejected with up to three arguments:
-        *
-        *   - errorCode Error code string
-        *   - error Error explanation
-        *   - details Further error details
-        */
-       MessagePosterFactory.prototype.create = function ( title, apiUrl ) {
-               var factory = this,
-                       api = apiUrl ? new mw.ForeignApi( apiUrl ) : new mw.Api();
-
-               return api.get( {
-                       formatversion: 2,
-                       action: 'query',
-                       prop: 'info',
-                       titles: title.getPrefixedDb()
-               } ).then( function ( data ) {
-                       var contentModel, moduleName, page = data.query.pages[ 0 ];
-                       if ( !page ) {
-                               return $.Deferred().reject( 'unexpected-response', 'Unexpected API response' );
-                       }
-                       contentModel = page.contentmodel;
-                       moduleName = 'mediawiki.messagePoster.' + contentModel;
-                       return mw.loader.using( moduleName ).then( function () {
-                               return factory.createForContentModel(
-                                       contentModel,
-                                       title,
-                                       api
-                               );
-                       }, function () {
-                               return $.Deferred().reject( 'failed-to-load-module', 'Failed to load "' + moduleName + '"' );
-                       } );
-               }, function ( error, details ) {
-                       return $.Deferred().reject( 'content-model-query-failed', error, details );
-               } );
-       };
-
-       /**
-        * Creates a MessagePoster instance, given a title and content model
-        *
-        * @private
-        * @param {string} contentModel Content model of title
-        * @param {mw.Title} title Title being posted to
-        * @param {mw.Api} api mw.Api instance that the instance should use
-        * @return {mw.messagePoster.MessagePoster}
-        */
-       MessagePosterFactory.prototype.createForContentModel = function ( contentModel, title, api ) {
-               return new this.contentModelToClass[ contentModel ]( title, api );
-       };
-
-       mw.messagePoster = {
-               factory: new MessagePosterFactory()
-       };
-}( mediaWiki, jQuery ) );
index 598293a..b1e1da3 100644 (file)
                         * @param {Function} [callback] Callback to run after request resolution
                         */
                        function addScript( src, callback ) {
-                               var promise = $.ajax( {
-                                       url: src,
-                                       dataType: 'script',
-                                       // Force jQuery behaviour to be for crossDomain. Otherwise jQuery would use
-                                       // XHR for a same domain request instead of <script>, which changes the request
-                                       // headers (potentially missing a cache hit), and reduces caching in general
-                                       // since browsers cache XHR much less (if at all). And XHR means we retrieve
-                                       // text, so we'd need to $.globalEval, which then messes up line numbers.
-                                       crossDomain: true,
-                                       cache: true
-                               } );
-
-                               if ( callback ) {
-                                       promise.always( callback );
-                               }
+                               var script = document.createElement( 'script' );
+                               script.src = src;
+                               script.onload = script.onerror = function () {
+                                       if ( script.parentNode ) {
+                                               script.parentNode.removeChild( script );
+                                       }
+                                       script = null;
+                                       if ( callback ) {
+                                               callback();
+                                               callback = null;
+                                       }
+                               };
+                               document.head.appendChild( script );
                        }
 
                        /**
index 274eb14..a7c9aa6 100644 (file)
@@ -25,36 +25,32 @@ environment variable to any value:
 
     DISPLAY=1 npm run selenium
 
-To run only one file (for example page.js), you first need to spawn the chromedriver:
+To run only one test (for example specs/page.js), you first need to start Chromedriver:
 
     chromedriver --url-base=wd/hub --port=4444
 
-Then in another terminal:
+Then, in another terminal:
 
-    cd tests/selenium
-    ../../node_modules/.bin/wdio --spec specs/page.js
+    npm run selenium-test -- --spec tests/selenium/specs/page.js
 
-To run only one test (name contains string 'preferences'):
+You can also filter specific cases, for ones that contain the string 'preferences':
 
-    ../../node_modules/.bin/wdio --spec specs/user.js --mochaOpts.grep preferences
+    npm run selenium-test -- tests/selenium/specs/user.js --mochaOpts.grep preferences
 
-The runner reads the config file `wdio.conf.js` and runs the spec listed in
-`page.js`.
-
-The defaults in the configuration files aim are targeting a MediaWiki-Vagrant
-installation on http://127.0.0.1:8080 with a user Admin and
-password 'vagrant'.  Those settings can be overridden using environment
+The runner reads the configuration from `wdio.conf.js`. The defaults target
+a MediaWiki-Vagrant installation on `http://127.0.0.1:8080` with a user "Admin"
+and password "vagrant".  Those settings can be overridden using environment
 variables:
 
-`MW_SERVER`: to be set to the value of your $wgServer
-`MW_SCRIPT_PATH`: ditto with $wgScriptPath
-`MEDIAWIKI_USER`: username of an account that can create users on the wiki
-`MEDIAWIKI_PASSWORD`: password for above user
+`MW_SERVER`: to be set to the value of your $wgServer
+`MW_SCRIPT_PATH`: ditto with $wgScriptPath
+`MEDIAWIKI_USER`: username of an account that can create users on the wiki
+`MEDIAWIKI_PASSWORD`: password for above user
 
 Example:
 
     MW_SERVER=http://example.org MW_SCRIPT_PATH=/dev/w npm run selenium
 
-## Links
+## Further reading
 
 - [Selenium/Node.js](https://www.mediawiki.org/wiki/Selenium/Node.js)
index a0b70a3..2bcef13 100644 (file)
@@ -1,9 +1,7 @@
-const Page = require( './page' ),
-       // https://github.com/Fannon/mwbot
-       MWBot = require( 'mwbot' );
+const Page = require( 'wdio-mediawiki/Page' ),
+       Api = require( 'wdio-mediawiki/Api' );
 
 class CreateAccountPage extends Page {
-
        get username() { return browser.element( '#wpName2' ); }
        get password() { return browser.element( '#wpPassword2' ); }
        get confirmPassword() { return browser.element( '#wpRetype' ); }
@@ -11,7 +9,7 @@ class CreateAccountPage extends Page {
        get heading() { return browser.element( '#firstHeading' ); }
 
        open() {
-               super.open( 'Special:CreateAccount' );
+               super.openTitle( 'Special:CreateAccount' );
        }
 
        createAccount( username, password ) {
@@ -22,23 +20,9 @@ class CreateAccountPage extends Page {
                this.create.click();
        }
 
+       // @deprecated Use wdio-mediawiki/Api#createAccount() instead.
        apiCreateAccount( username, password ) {
-               let bot = new MWBot();
-
-               return bot.loginGetCreateaccountToken( {
-                       apiUrl: `${browser.options.baseUrl}/api.php`,
-                       username: browser.options.username,
-                       password: browser.options.password
-               } ).then( function () {
-                       return bot.request( {
-                               action: 'createaccount',
-                               createreturnurl: browser.options.baseUrl,
-                               createtoken: bot.createaccountToken,
-                               username: username,
-                               password: password,
-                               retype: password
-                       } );
-               } );
+               return Api.createAccount( username, password );
        }
 }
 
index ec03409..1218818 100644 (file)
@@ -1,6 +1,5 @@
-const Page = require( './page' ),
-       // https://github.com/Fannon/mwbot
-       MWBot = require( 'mwbot' );
+const Page = require( 'wdio-mediawiki/Page' ),
+       Api = require( 'wdio-mediawiki/Api' );
 
 class DeletePage extends Page {
        get reason() { return browser.element( '#wpReason' ); }
@@ -8,26 +7,19 @@ class DeletePage extends Page {
        get submit() { return browser.element( '#wpConfirmB' ); }
        get displayedContent() { return browser.element( '#mw-content-text' ); }
 
-       open( name ) {
-               super.open( name + '&action=delete' );
+       open( title ) {
+               super.openTitle( title, { action: 'delete' } );
        }
 
-       delete( name, reason ) {
-               this.open( name );
+       delete( title, reason ) {
+               this.open( title );
                this.reason.setValue( reason );
                this.submit.click();
        }
 
+       // @deprecated Use wdio-mediawiki/Api#delete() instead.
        apiDelete( name, reason ) {
-               let bot = new MWBot();
-
-               return bot.loginGetEditToken( {
-                       apiUrl: `${browser.options.baseUrl}/api.php`,
-                       username: browser.options.username,
-                       password: browser.options.password
-               } ).then( function () {
-                       return bot.delete( name, reason );
-               } );
+               return Api.delete( name, reason );
        }
 }
 
index a1784f4..8bc7dc6 100644 (file)
@@ -1,6 +1,5 @@
-const Page = require( './page' ),
-       // https://github.com/Fannon/mwbot
-       MWBot = require( 'mwbot' );
+const Page = require( 'wdio-mediawiki/Page' ),
+       Api = require( 'wdio-mediawiki/Api' );
 
 class EditPage extends Page {
        get content() { return browser.element( '#wpTextbox1' ); }
@@ -8,8 +7,8 @@ class EditPage extends Page {
        get heading() { return browser.element( '#firstHeading' ); }
        get save() { return browser.element( '#wpSave' ); }
 
-       openForEditing( name ) {
-               super.open( name + '&action=edit' );
+       openForEditing( title ) {
+               super.openTitle( title, { action: 'edit' } );
        }
 
        edit( name, content ) {
@@ -18,16 +17,9 @@ class EditPage extends Page {
                this.save.click();
        }
 
+       // @deprecated Use wdio-mediawiki/Api#edit() instead.
        apiEdit( name, content ) {
-               let bot = new MWBot();
-
-               return bot.loginGetEditToken( {
-                       apiUrl: `${browser.options.baseUrl}/api.php`,
-                       username: browser.options.username,
-                       password: browser.options.password
-               } ).then( function () {
-                       return bot.edit( name, content, `Created page with "${content}"` );
-               } );
+               return Api.edit( name, content );
        }
 }
 
index 60d7fd4..acaf3ea 100644 (file)
@@ -1,10 +1,10 @@
-const Page = require( './page' );
+const Page = require( 'wdio-mediawiki/Page' );
 
 class HistoryPage extends Page {
        get comment() { return browser.element( '#pagehistory .comment' ); }
 
-       open( name ) {
-               super.open( name + '&action=history' );
+       open( title ) {
+               super.openTitle( title, { action: 'history' } );
        }
 }
 
index 0974086..f159990 100644 (file)
@@ -1,11 +1,12 @@
+const Page = require( 'wdio-mediawiki/Page' );
+
 /**
- * Based on http://webdriver.io/guide/testrunner/pageobjects.html
+ * @deprecated Use wdio-mediawiki/Page and openTitle() instead.
  */
-
-class Page {
+class LegacyPage extends Page {
        open( path ) {
                browser.url( browser.options.baseUrl + '/index.php?title=' + path );
        }
 }
 
-module.exports = Page;
+module.exports = LegacyPage;
index 9456b61..64fd582 100644 (file)
@@ -1,11 +1,11 @@
-const Page = require( './page' );
+const Page = require( 'wdio-mediawiki/Page' );
 
 class PreferencesPage extends Page {
        get realName() { return browser.element( '#mw-input-wprealname' ); }
        get save() { return browser.element( '#prefcontrol' ); }
 
        open() {
-               super.open( 'Special:Preferences' );
+               super.openTitle( 'Special:Preferences' );
        }
 
        changeRealName( realName ) {
index be5be8c..47ad145 100644 (file)
@@ -1,17 +1,16 @@
-const Page = require( './page' );
+const Page = require( 'wdio-mediawiki/Page' );
 
 class RestorePage extends Page {
-
        get reason() { return browser.element( '#wpComment' ); }
        get submit() { return browser.element( '#mw-undelete-submit' ); }
        get displayedContent() { return browser.element( '#mw-content-text' ); }
 
-       open( name ) {
-               super.open( 'Special:Undelete/' + name );
+       open( subject ) {
+               super.openTitle( 'Special:Undelete/' + subject );
        }
 
-       restore( name, reason ) {
-               this.open( name );
+       restore( subject, reason ) {
+               this.open( subject );
                this.reason.setValue( reason );
                this.submit.click();
        }
index 557fb6b..971e21b 100644 (file)
@@ -1,25 +1,6 @@
-const Page = require( './page' );
+const LoginPage = require( 'wdio-mediawiki/LoginPage' );
 
-class UserLoginPage extends Page {
-       get username() { return browser.element( '#wpName1' ); }
-       get password() { return browser.element( '#wpPassword1' ); }
-       get loginButton() { return browser.element( '#wpLoginAttempt' ); }
-       get userPage() { return browser.element( '#pt-userpage' ); }
-
-       open() {
-               super.open( 'Special:UserLogin' );
-       }
-
-       login( username, password ) {
-               this.open();
-               this.username.setValue( username );
-               this.password.setValue( password );
-               this.loginButton.click();
-       }
-
-       loginAdmin() {
-               this.login( browser.options.username, browser.options.password );
-       }
-}
-
-module.exports = new UserLoginPage();
+/**
+ * @deprecated Use wdio-mediawiki/LoginPage instead.
+ */
+module.exports = LoginPage;
index 197a235..a1fd480 100644 (file)
@@ -1,4 +1,5 @@
 const assert = require( 'assert' ),
+       Api = require( 'wdio-mediawiki/Api' ),
        DeletePage = require( '../pageobjects/delete.page' ),
        RestorePage = require( '../pageobjects/restore.page' ),
        EditPage = require( '../pageobjects/edit.page' ),
@@ -39,12 +40,12 @@ describe( 'Page', function () {
 
                // create
                browser.call( function () {
-                       return EditPage.apiEdit( name, initialContent );
+                       return Api.edit( name, initialContent );
                } );
 
                // delete
                browser.call( function () {
-                       return DeletePage.apiDelete( name, 'delete prior to recreate' );
+                       return Api.delete( name, 'delete prior to recreate' );
                } );
 
                // create
@@ -53,13 +54,12 @@ describe( 'Page', function () {
                // check
                assert.equal( EditPage.heading.getText(), name );
                assert.equal( EditPage.displayedContent.getText(), content );
-
        } );
 
        it( 'should be editable', function () {
                // create
                browser.call( function () {
-                       return EditPage.apiEdit( name, content );
+                       return Api.edit( name, content );
                } );
 
                // edit
@@ -73,7 +73,7 @@ describe( 'Page', function () {
        it( 'should have history', function () {
                // create
                browser.call( function () {
-                       return EditPage.apiEdit( name, content );
+                       return Api.edit( name, content );
                } );
 
                // check
@@ -87,7 +87,7 @@ describe( 'Page', function () {
 
                // create
                browser.call( function () {
-                       return EditPage.apiEdit( name, content );
+                       return Api.edit( name, content );
                } );
 
                // delete
@@ -106,12 +106,12 @@ describe( 'Page', function () {
 
                // create
                browser.call( function () {
-                       return EditPage.apiEdit( name, content );
+                       return Api.edit( name, content );
                } );
 
                // delete
                browser.call( function () {
-                       return DeletePage.apiDelete( name, content + '-deletereason' );
+                       return Api.delete( name, content + '-deletereason' );
                } );
 
                // restore
index 62aac05..10bf05d 100644 (file)
@@ -1,7 +1,8 @@
 const assert = require( 'assert' ),
        CreateAccountPage = require( '../pageobjects/createaccount.page' ),
        PreferencesPage = require( '../pageobjects/preferences.page' ),
-       UserLoginPage = require( '../pageobjects/userlogin.page' );
+       UserLoginPage = require( 'wdio-mediawiki/LoginPage' ),
+       Api = require( 'wdio-mediawiki/Api' );
 
 describe( 'User', function () {
        var password,
@@ -30,7 +31,7 @@ describe( 'User', function () {
        it( 'should be able to log in', function () {
                // create
                browser.call( function () {
-                       return CreateAccountPage.apiCreateAccount( username, password );
+                       return Api.createAccount( username, password );
                } );
 
                // log in
@@ -45,7 +46,7 @@ describe( 'User', function () {
 
                // create
                browser.call( function () {
-                       return CreateAccountPage.apiCreateAccount( username, password );
+                       return Api.createAccount( username, password );
                } );
 
                // log in
diff --git a/tests/selenium/wdio-mediawiki/.eslintrc.json b/tests/selenium/wdio-mediawiki/.eslintrc.json
new file mode 100644 (file)
index 0000000..a49d096
--- /dev/null
@@ -0,0 +1,10 @@
+{
+       "extends": "wikimedia",
+       "env": {
+               "es6": true,
+               "node": true
+       },
+       "globals": {
+               "browser": false
+       }
+}
diff --git a/tests/selenium/wdio-mediawiki/Api.js b/tests/selenium/wdio-mediawiki/Api.js
new file mode 100644 (file)
index 0000000..40bce32
--- /dev/null
@@ -0,0 +1,77 @@
+const MWBot = require( 'mwbot' );
+
+// TODO: Once we require Node 7 or later, we can use async-await.
+
+module.exports = {
+       /**
+        * Shortcut for `MWBot#edit( .. )`.
+        *
+        * @since 1.0.0
+        * @see <https://www.mediawiki.org/wiki/API:Edit>
+        * @param {string} title
+        * @param {string} content
+        * @return {Object} Promise for API action=edit response data.
+        */
+       edit( title, content ) {
+               let bot = new MWBot();
+
+               return bot.loginGetEditToken( {
+                       apiUrl: `${browser.options.baseUrl}/api.php`,
+                       username: browser.options.username,
+                       password: browser.options.password
+               } ).then( function () {
+                       return bot.edit( title, content, `Created page with "${content}"` );
+               } );
+       },
+
+       /**
+        * Shortcut for `MWBot#delete( .. )`.
+        *
+        * @since 1.0.0
+        * @see <https://www.mediawiki.org/wiki/API:Delete>
+        * @param {string} title
+        * @param {string} reason
+        * @return {Object} Promise for API action=delete response data.
+        */
+       delete( title, reason ) {
+               let bot = new MWBot();
+
+               return bot.loginGetEditToken( {
+                       apiUrl: `${browser.options.baseUrl}/api.php`,
+                       username: browser.options.username,
+                       password: browser.options.password
+               } ).then( function () {
+                       return bot.delete( title, reason );
+               } );
+       },
+
+       /**
+        * Shortcut for `MWBot#request( { acount: 'createaccount', .. } )`.
+        *
+        * @since 1.0.0
+        * @see <https://www.mediawiki.org/wiki/API:Account_creation>
+        * @param {string} username
+        * @param {string} password
+        * @return {Object} Promise for API action=createaccount response data.
+        */
+       createAccount( username, password ) {
+               let bot = new MWBot();
+
+               // Log in as admin
+               return bot.loginGetCreateaccountToken( {
+                       apiUrl: `${browser.options.baseUrl}/api.php`,
+                       username: browser.options.username,
+                       password: browser.options.password
+               } ).then( function () {
+                       // Create the new account
+                       return bot.request( {
+                               action: 'createaccount',
+                               createreturnurl: browser.options.baseUrl,
+                               createtoken: bot.createaccountToken,
+                               username: username,
+                               password: password,
+                               retype: password
+                       } );
+               } );
+       }
+};
diff --git a/tests/selenium/wdio-mediawiki/BlankPage.js b/tests/selenium/wdio-mediawiki/BlankPage.js
new file mode 100644 (file)
index 0000000..ed99bd4
--- /dev/null
@@ -0,0 +1,11 @@
+const Page = require( 'wdio-mediawiki/Page' );
+
+class BlankPage extends Page {
+       get heading() { return browser.element( '#firstHeading' ); }
+
+       open() {
+               super.openTitle( 'Special:BlankPage', { uselang: 'en' } );
+       }
+}
+
+module.exports = new BlankPage();
diff --git a/tests/selenium/wdio-mediawiki/CHANGELOG.md b/tests/selenium/wdio-mediawiki/CHANGELOG.md
new file mode 100644 (file)
index 0000000..bfce387
--- /dev/null
@@ -0,0 +1,8 @@
+# Notable changes
+
+## [Unreleased]
+
+* Api: Added initial version.
+* Page: Added initial version.
+* BlankPage: Added initial version.
+* LoginPage: Added initial version.
diff --git a/tests/selenium/wdio-mediawiki/LICENSE b/tests/selenium/wdio-mediawiki/LICENSE
new file mode 100644 (file)
index 0000000..ad55501
--- /dev/null
@@ -0,0 +1,21 @@
+Copyright 2018 Å½eljko Filipin
+Copyright 2018 Timo Tijhof
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/tests/selenium/wdio-mediawiki/LoginPage.js b/tests/selenium/wdio-mediawiki/LoginPage.js
new file mode 100644 (file)
index 0000000..d07934b
--- /dev/null
@@ -0,0 +1,25 @@
+const Page = require( 'wdio-mediawiki/Page' );
+
+class LoginPage extends Page {
+       get username() { return browser.element( '#wpName1' ); }
+       get password() { return browser.element( '#wpPassword1' ); }
+       get loginButton() { return browser.element( '#wpLoginAttempt' ); }
+       get userPage() { return browser.element( '#pt-userpage' ); }
+
+       open() {
+               super.openTitle( 'Special:UserLogin' );
+       }
+
+       login( username, password ) {
+               this.open();
+               this.username.setValue( username );
+               this.password.setValue( password );
+               this.loginButton.click();
+       }
+
+       loginAdmin() {
+               this.login( browser.options.username, browser.options.password );
+       }
+}
+
+module.exports = new LoginPage();
diff --git a/tests/selenium/wdio-mediawiki/Page.js b/tests/selenium/wdio-mediawiki/Page.js
new file mode 100644 (file)
index 0000000..48620e6
--- /dev/null
@@ -0,0 +1,23 @@
+const querystring = require( 'querystring' );
+
+/**
+ * Based on http://webdriver.io/guide/testrunner/pageobjects.html
+ */
+class Page {
+
+       /**
+        * Navigate the browser to a given page.
+        *
+        * @since 1.0.0
+        * @see <http://webdriver.io/api/protocol/url.html>
+        * @param {string} title Page title
+        * @param {Object} [query] Query parameter
+        * @return {void} This method runs a browser command.
+        */
+       openTitle( title, query = {} ) {
+               query.title = title;
+               browser.url( browser.options.baseUrl + '/index.php?' + querystring.stringify( query ) );
+       }
+}
+
+module.exports = Page;
diff --git a/tests/selenium/wdio-mediawiki/README.md b/tests/selenium/wdio-mediawiki/README.md
new file mode 100644 (file)
index 0000000..260dc77
--- /dev/null
@@ -0,0 +1,53 @@
+# wdio-mediawiki
+
+A plugin for [WebdriverIO](http://webdriver.io/) providing utilities to simplify testing of MediaWiki features.
+
+## Getting Started
+
+### Page
+
+The `Page` class is a base class for following the [Page Objects Pattern](http://webdriver.io/guide/testrunner/pageobjects.html).
+
+* `openTitle( title [, Object query ] )`
+
+The convention is for implementations to extend this class and provide an `open()` method
+that calls `super.openTitle()`, as well as add various getters for elements on the page.
+
+See [BlankPage](./BlankPage.js) and [specs/BlankPage](./specs/BlankPage.js) for an example.
+
+### Api
+
+Utilities to interact with the MediaWiki API. Uses the [mwbot](https://github.com/Fannon/mwbot) library.
+
+Actions are performed logged-in using `browser.options.username` and `browser.options.password`,
+which typically come from `MEDIAWIKI_USER` and `MEDIAWIKI_PASSWORD` environment variables.
+
+* `edit(title, content)`
+* `delete(title, reason)`
+* `createAccount(username, password)`
+
+## Versioning
+
+This package follows [Semantic Versioning guidelines](https://semver.org/) for its releases. In
+particular, its major version must be bumped when compatibility is removed for a previous of
+MediaWiki.
+
+It is the expectation that this module will only support a single version of MediaWiki at any
+given time, and that tests in older branches of MediaWiki-related projects naturally use the older
+release line of this package.
+
+In order to allow for smooth and decentralised upgrades, it is recommended that the only type of
+breaking change made to this package is a change that removes something. Thus, in order to change
+something, it must either be backwards-compatible, or must be introduced as a new method that
+co-exists with its deprecated equivalent for at least one release.
+
+## Issue tracker
+
+Please report issues to [Phabricator](https://phabricator.wikimedia.org/tag/mediawiki-core-tests/).
+
+## Contributing
+
+This module is maintained in the MediaWiki core repository and published from there as a
+package to npmjs.org. To simplify development and to ensure changes are verified
+automatically, MediaWiki core itself uses this module directly from the working copy
+using [npm Local Paths](https://docs.npmjs.com/files/package.json#local-paths).
diff --git a/tests/selenium/wdio-mediawiki/index.js b/tests/selenium/wdio-mediawiki/index.js
new file mode 100644 (file)
index 0000000..d3171be
--- /dev/null
@@ -0,0 +1,26 @@
+const fs = require( 'fs' );
+
+module.exports = {
+       /**
+        * Based on <https://github.com/webdriverio/webdriverio/issues/269#issuecomment-306342170>
+        *
+        * @since 1.0.0
+        * @param {string} title Description (will be sanitised and used as file name)
+        * @return {string} File path
+        */
+       saveScreenshot( title ) {
+               var filename, filePath;
+               // Create sane file name for current test title
+               filename = encodeURIComponent( title.replace( /\s+/g, '-' ) );
+               filePath = `${browser.options.screenshotPath}/${filename}.png`;
+               // Ensure directory exists, based on WebDriverIO#saveScreenshotSync()
+               try {
+                       fs.statSync( browser.options.screenshotPath );
+               } catch ( err ) {
+                       fs.mkdirSync( browser.options.screenshotPath );
+               }
+               // Create and save screenshot
+               browser.saveScreenshot( filePath );
+               return filePath;
+       }
+};
diff --git a/tests/selenium/wdio-mediawiki/package.json b/tests/selenium/wdio-mediawiki/package.json
new file mode 100644 (file)
index 0000000..be7ed33
--- /dev/null
@@ -0,0 +1,21 @@
+{
+  "name": "wdio-mediawiki",
+  "version": "0.1.0",
+  "description": "WebdriverIO plugin for testing a MediaWiki site.",
+  "homepage": "https://gerrit.wikimedia.org/g/mediawiki/core/+/master/tests/selenium/wdio-mediawiki/",
+  "license": "MIT",
+  "keywords": [
+    "mediawiki",
+    "wdio-plugin"
+  ],
+  "files": [
+    "*.js",
+    "specs/"
+  ],
+  "engines": {
+    "node" : ">=6.0"
+  },
+  "dependencies": {
+    "mwbot": "1.0.10"
+  }
+}
diff --git a/tests/selenium/wdio-mediawiki/specs/BlankPage.js b/tests/selenium/wdio-mediawiki/specs/BlankPage.js
new file mode 100644 (file)
index 0000000..f84ae90
--- /dev/null
@@ -0,0 +1,11 @@
+const assert = require( 'assert' ),
+       BlankPage = require( 'wdio-mediawiki/BlankPage' );
+
+describe( 'BlankPage', function () {
+       it( 'should have its title', function () {
+               BlankPage.open();
+
+               // check
+               assert.equal( BlankPage.heading.getText(), 'Blank page' );
+       } );
+} );
index 260d188..f785d36 100644 (file)
@@ -1,5 +1,6 @@
 const fs = require( 'fs' ),
        path = require( 'path' ),
+       saveScreenshot = require( 'wdio-mediawiki' ).saveScreenshot,
        logPath = process.env.LOG_DIR || __dirname + '/log';
 
 function relPath( foo ) {
@@ -11,33 +12,43 @@ exports.config = {
        // Custom WDIO config specific to MediaWiki
        // ======
        // Use in a test as `browser.options.<key>`.
-
-       // Configure wiki admin user/pass via env
        // 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'
+       ),
+
        // ======
        // Sauce Labs
        // ======
+       // See http://webdriver.io/guide/services/sauce.html
+       // and https://docs.saucelabs.com/reference/platforms-configurator
        services: [ 'sauce' ],
        user: process.env.SAUCE_USERNAME,
        key: process.env.SAUCE_ACCESS_KEY,
 
+       // Default timeout in milliseconds for Selenium Grid requests
+       connectionRetryTimeout: 90 * 1000,
+
+       // Default request retries count
+       connectionRetryCount: 3,
+
        // ==================
-       // Specify Test Files
+       // Test Files
        // ==================
-       // Define which test specs should run. The pattern is relative to the directory
-       // from which `wdio` was called. Notice that, if you are calling `wdio` from an
-       // NPM script (see https://docs.npmjs.com/cli/run-script) then the current working
-       // directory is where your package.json resides, so `wdio` will be called from there.
        specs: [
+               relPath( './tests/selenium/wdio-mediawiki/specs/*.js' ),
                relPath( './tests/selenium/specs/**/*.js' ),
                relPath( './extensions/*/tests/selenium/specs/**/*.js' ),
                relPath( './extensions/VisualEditor/modules/ve-mw/tests/selenium/specs/**/*.js' ),
                relPath( './skins/*/tests/selenium/specs/**/*.js' )
        ],
-       // Patterns to exclude.
+       // Patterns to exclude
        exclude: [
                relPath( './extensions/CirrusSearch/tests/selenium/specs/**/*.js' )
        ],
@@ -45,51 +56,37 @@ exports.config = {
        // ============
        // Capabilities
        // ============
-       // Define your capabilities here. WebdriverIO can run multiple capabilities at the same
-       // time. Depending on the number of capabilities, WebdriverIO launches several test
-       // sessions. Within your capabilities you can overwrite the spec and exclude options in
-       // order to group specific specs to a specific capability.
 
-       // First, you can define how many instances should be started at the same time. Let's
-       // say you have 3 different capabilities (Chrome, Firefox, and Safari) and you have
-       // set maxInstances to 1; wdio will spawn 3 processes. Therefore, if you have 10 spec
-       // files and you set maxInstances to 10, all spec files will get tested at the same time
-       // and 30 processes will get spawned. The property handles how many capabilities
-       // from the same test should run tests.
+       // How many instances of the same capability (browser) may be started at the same time.
        maxInstances: 1,
 
-       // If you have trouble getting all important capabilities together, check out the
-       // Sauce Labs platform configurator - a great tool to configure your capabilities:
-       // https://docs.saucelabs.com/reference/platforms-configurator
-       //
-       // For Chrome/Chromium https://sites.google.com/a/chromium.org/chromedriver/capabilities
        capabilities: [ {
-               // maxInstances can get overwritten per capability. So if you have an in-house Selenium
-               // grid with only 5 firefox instances available you can make sure that not more than
-               // 5 instances get started at a time.
-               maxInstances: 1,
+               // For Chrome/Chromium https://sites.google.com/a/chromium.org/chromedriver/capabilities
                browserName: 'chrome',
+               maxInstances: 1,
                chromeOptions: {
-                       // If DISPLAY is set, assume running from developer machine and/or with Xvfb.
+                       // 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' ]
-                       ).concat(
+                       args: [
+                               ...( process.env.DISPLAY ? [] : [ '--headless' ] ),
                                // Chrome sandbox does not work in Docker
-                               fs.existsSync( '/.dockerenv' ) ? [ '--no-sandbox' ] : []
-                       )
+                               ...( fs.existsSync( '/.dockerenv' ) ? [ '--no-sandbox' ] : [] )
+                       ]
                }
        } ],
 
        // ===================
        // Test Configurations
        // ===================
-       // Define all options that are relevant for the WebdriverIO instance here
+
+       // Enabling synchronous mode (via the wdio-sync package), means specs don't have to
+       // use Promise#then() or await for browser commands, such as like `brower.element()`.
+       // Instead, it will automatically pause JavaScript execution until th command finishes.
        //
-       // By default WebdriverIO commands are executed in a synchronous way using
-       // the wdio-sync package. If you still want to run your tests in an async way
-       // e.g. using promises you can set the sync option to false.
+       // For non-browser commands (such as MWBot and other promises), this means you
+       // have to use `browser.call()` to make sure WDIO waits for it before the next
+       // browser command.
        sync: true,
 
        // Level of logging verbosity: silent | verbose | command | data | result | error
@@ -101,67 +98,23 @@ exports.config = {
        // Warns when a deprecated command is used
        deprecationWarnings: true,
 
-       // If you only want to run your tests until a specific amount of tests have failed use
-       // bail (default is 0 - don't bail, run all tests).
+       // Stop the tests once a certain number of failed tests have been recorded.
+       // Default is 0 - don't bail, run all tests.
        bail: 0,
 
-       // Saves a screenshot to a given path if a command fails.
+       // Setting this enables automatic screenshots for when a browser command fails
+       // It is also used by afterTest for capturig failed assertions.
        screenshotPath: logPath,
 
-       // Set a base URL in order to shorten url command calls. If your `url` parameter starts
-       // with `/`, the base url gets prepended, not including the path portion of your baseUrl.
-       // If your `url` parameter starts without a scheme or `/` (like `some/path`), the base url
-       // gets prepended directly.
-       baseUrl: (
-               process.env.MW_SERVER || 'http://127.0.0.1:8080'
-       ) + (
-               process.env.MW_SCRIPT_PATH || '/w'
-       ),
-
-       // Default timeout for all waitFor* commands.
-       waitforTimeout: 10000,
+       // Default timeout for each waitFor* command.
+       waitforTimeout: 10 * 1000,
 
-       // Default timeout in milliseconds for request
-       // if Selenium Grid doesn't send response
-       connectionRetryTimeout: 90000,
-
-       // Default request retries count
-       connectionRetryCount: 3,
-
-       // Initialize the browser instance with a WebdriverIO plugin. The object should have the
-       // plugin name as key and the desired plugin options as properties. Make sure you have
-       // the plugin installed before running any tests. The following plugins are currently
-       // available:
-       // WebdriverCSS: https://github.com/webdriverio/webdrivercss
-       // WebdriverRTC: https://github.com/webdriverio/webdriverrtc
-       // Browserevent: https://github.com/webdriverio/browserevent
-       // plugins: {
-       //      webdrivercss: {
-       //              screenshotRoot: 'my-shots',
-       //              failedComparisonsRoot: 'diffs',
-       //              misMatchTolerance: 0.05,
-       //              screenWidth: [320,480,640,1024]
-       //      },
-       //      webdriverrtc: {},
-       //      browserevent: {}
-       // },
-       //
-       // Test runner services
-       // Services take over a specific job you don't want to take care of. They enhance
-       // your test setup with almost no effort. Unlike plugins, they don't add new
-       // commands. Instead, they hook themselves up into the test process.
-       // services: [],//
        // Framework you want to run your specs with.
-       // The following are supported: Mocha, Jasmine, and Cucumber
-       // see also: http://webdriver.io/guide/testrunner/frameworks.html
-       //
-       // Make sure you have the wdio adapter package for the specific framework installed
-       // before running any tests.
+       // See also: http://webdriver.io/guide/testrunner/frameworks.html
        framework: 'mocha',
 
        // Test reporter for stdout.
-       // The only one supported by default is 'dot'
-       // see also: http://webdriver.io/guide/testrunner/reporters.html
+       // See also: http://webdriver.io/guide/testrunner/reporters.html
        reporters: [ 'spec', 'junit' ],
        reporterOptions: {
                junit: {
@@ -173,146 +126,24 @@ exports.config = {
        // See the full list at http://mochajs.org/
        mochaOpts: {
                ui: 'bdd',
-               timeout: 60000
+               timeout: 60 * 1000
        },
 
        // =====
        // Hooks
        // =====
-       // WebdriverIO provides several hooks you can use to interfere with the test process in order to enhance
-       // it and to build services around it. You can either apply a single function or an array of
-       // methods to it. If one of them returns with a promise, WebdriverIO will wait until that promise got
-       // resolved to continue.
-
-       /**
-        * Gets executed once before all workers get launched.
-        * @param {Object} config wdio configuration object
-        * @param {Array.<Object>} capabilities list of capabilities details
-        */
-       // onPrepare: function (config, capabilities) {
-       // },
-
-       /**
-        * Gets executed just before initialising the webdriver session and test framework. It allows you
-        * to manipulate configurations depending on the capability or spec.
-        * @param {Object} config wdio configuration object
-        * @param {Array.<Object>} capabilities list of capabilities details
-        * @param {Array.<String>} specs List of spec file paths that are to be run
-        */
-       // beforeSession: function (config, capabilities, specs) {
-       // },
-
-       /**
-        * Gets executed before test execution begins. At this point you can access to all global
-        * variables like `browser`. It is the perfect place to define custom commands.
-        * @param {Array.<Object>} capabilities list of capabilities details
-        * @param {Array.<String>} specs List of spec file paths that are to be run
-        */
-       // before: function (capabilities, specs) {
-       // },
+       // See also: http://webdriver.io/guide/testrunner/configurationfile.html
 
        /**
-        * Runs before a WebdriverIO command gets executed.
-        * @param {String} commandName hook command name
-        * @param {Array} args arguments that command would receive
+        * Save a screenshot when test fails.
+        *
+        * @param {Object} test Mocha Test object
         */
-       // beforeCommand: function (commandName, args) {
-       // },
-
-       /**
-        * Hook that gets executed before the suite starts
-        * @param {Object} suite suite details
-        */
-       // beforeSuite: function (suite) {
-       // },
-
-       /**
-        * Function to be executed before a test (in Mocha/Jasmine) or a step (in Cucumber) starts.
-        * @param {Object} test test details
-        */
-       // beforeTest: function (test) {
-       // },
-
-       /**
-        * Hook that gets executed _before_ a hook within the suite starts (e.g. runs before calling
-        * beforeEach in Mocha)
-        */
-       // beforeHook: function () {
-       // },
-
-       /**
-        * Hook that gets executed _after_ a hook within the suite ends (e.g. runs after calling
-        * afterEach in Mocha)
-        */
-       // afterHook: function () {
-       // },
-       /**
-        * Function to be executed after a test (in Mocha/Jasmine) or a step (in Cucumber) ends.
-        * @param {Object} test test details
-        */
-       // from https://github.com/webdriverio/webdriverio/issues/269#issuecomment-306342170
        afterTest: function ( test ) {
-               var filename, filePath;
-               // if test passed, ignore, else take and save screenshot
-               if ( test.passed ) {
-                       return;
-               }
-               // Create sane file name for current test title
-               filename = encodeURIComponent( test.title.replace( /\s+/g, '-' ) );
-               filePath = `${browser.options.screenshotPath}/${filename}.png`;
-               // Ensure directory exists, based on WebDriverIO#saveScreenshotSync()
-               try {
-                       fs.statSync( browser.options.screenshotPath );
-               } catch ( err ) {
-                       fs.mkdirSync( browser.options.screenshotPath );
+               var filePath;
+               if ( !test.passed ) {
+                       filePath = saveScreenshot( test.title );
+                       console.log( '\n\tScreenshot: ' + filePath + '\n' );
                }
-               // Create and save screenshot
-               browser.saveScreenshot( filePath );
-               console.log( '\n\tScreenshot location:', filePath, '\n' );
        }
-
-       /**
-        * Hook that gets executed after the suite has ended
-        * @param {Object} suite suite details
-        */
-       // afterSuite: function (suite) {
-       // },
-
-       /**
-        * Runs after a WebdriverIO command gets executed
-        * @param {String} commandName hook command name
-        * @param {Array} args arguments that command would receive
-        * @param {Number} result 0 - command success, 1 - command error
-        * @param {Object} error error object if any
-        */
-       // afterCommand: function (commandName, args, result, error) {
-       // },
-
-       /**
-        * Gets executed after all tests are done. You still have access to all global variables from
-        * the test.
-        * @param {Number} result 0 - test pass, 1 - test fail
-        * @param {Array.<Object>} capabilities list of capabilities details
-        * @param {Array.<String>} specs List of spec file paths that ran
-        */
-       // after: function (result, capabilities, specs) {
-       // },
-
-       /**
-        * Gets executed right after terminating the webdriver session.
-        * @param {Object} config wdio configuration object
-        * @param {Array.<Object>} capabilities list of capabilities details
-        * @param {Array.<String>} specs List of spec file paths that ran
-        */
-       // afterSession: function (config, capabilities, specs) {
-       // },
-
-       /**
-        * Gets executed after all workers got shut down and the process is about to exit.
-        * @param {Object} exitCode 0 - success, 1 - fail
-        * @param {Object} config wdio configuration object
-        * @param {Array.<Object>} capabilities list of capabilities details
-        */
-       // onComplete: function(exitCode, config, capabilities) {
-       // }
 };