Merge "Selenium: pass -no-sandbox to Chrome under Docker"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Fri, 13 Apr 2018 01:43:36 +0000 (01:43 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Fri, 13 Apr 2018 01:43:36 +0000 (01:43 +0000)
43 files changed:
RELEASE-NOTES-1.31
includes/Storage/NameTableStore.php
includes/api/ApiParse.php
includes/api/ApiQueryRecentChanges.php
includes/api/ApiQueryUserContributions.php
includes/api/ApiQueryWatchlist.php
includes/api/ApiUserrights.php
includes/changes/RecentChange.php
includes/diff/DifferenceEngine.php
includes/filerepo/file/LocalFile.php
includes/jobqueue/JobQueueDB.php
includes/libs/rdbms/loadbalancer/ILoadBalancer.php
includes/libs/rdbms/loadbalancer/LoadBalancer.php
includes/logging/LogEntry.php
includes/objectcache/SqlBagOStuff.php
includes/page/WikiPage.php
includes/specialpage/ChangesListSpecialPage.php
includes/specials/SpecialRecentchanges.php
includes/specials/SpecialWatchlist.php
includes/user/User.php
languages/i18n/en.json
languages/i18n/qqq.json
maintenance/Maintenance.php
mw-config/config.css
resources/src/jquery/jquery.makeCollapsible.css
resources/src/jquery/jquery.makeCollapsible.js
resources/src/mediawiki.widgets/mw.widgets.TitleOptionWidget.js
tests/parser/ParserTestRunner.php
tests/phpunit/includes/MediaWikiServicesTest.php
tests/phpunit/includes/TestUserRegistry.php
tests/phpunit/includes/api/ApiMainTest.php
tests/phpunit/includes/api/ApiParseTest.php
tests/phpunit/includes/api/ApiTestCase.php
tests/phpunit/includes/api/ApiUserrightsTest.php [new file with mode: 0644]
tests/phpunit/includes/auth/AuthManagerTest.php
tests/phpunit/includes/db/LoadBalancerTest.php
tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php
tests/phpunit/includes/jobqueue/JobQueueTest.php
tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php
tests/phpunit/includes/registration/VersionCheckerTest.php
tests/phpunit/includes/specialpage/ChangesListSpecialPageTest.php
tests/phpunit/includes/utils/BatchRowUpdateTest.php
tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php

index f40a422..ea3aa8b 100644 (file)
@@ -331,6 +331,8 @@ changes to languages because of Phabricator reports.
 * The type string for the parameter $lang of DateFormatter::getInstance is
   deprecated.
 * Wikimedia\Rdbms\SavepointPostgres is deprecated.
+* The DO_MAINTENANCE constant is deprecated. RUN_MAINTENANCE_IF_MAIN should be
+  used instead.
 
 === Other changes in 1.31 ===
 * Browser support for Internet Explorer 10 was lowered from Grade A to Grade C.
index a1eba74..465f299 100644 (file)
@@ -138,7 +138,7 @@ class NameTableStore {
                                // RACE: $name was already in the db, probably just inserted, so load from master
                                // Use DBO_TRX to avoid missing inserts due to other threads or REPEATABLE-READs
                                $table = $this->loadTable(
-                                       $this->getDBConnection( DB_MASTER, LoadBalancer::CONN_TRX_AUTO )
+                                       $this->getDBConnection( DB_MASTER, LoadBalancer::CONN_TRX_AUTOCOMMIT )
                                );
                                $searchResult = array_search( $name, $table, true );
                                if ( $searchResult === false ) {
index cbd62a9..099d278 100644 (file)
@@ -243,12 +243,6 @@ class ApiParse extends ApiBase {
                        if ( $params['onlypst'] ) {
                                // Build a result and bail out
                                $result_array = [];
-                               if ( $this->contentIsDeleted ) {
-                                       $result_array['textdeleted'] = true;
-                               }
-                               if ( $this->contentIsSuppressed ) {
-                                       $result_array['textsuppressed'] = true;
-                               }
                                $result_array['text'] = $this->pstContent->serialize( $format );
                                $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'text';
                                if ( isset( $prop['wikitext'] ) ) {
@@ -400,8 +394,8 @@ class ApiParse extends ApiBase {
                }
 
                if ( isset( $prop['displaytitle'] ) ) {
-                       $result_array['displaytitle'] = $p_result->getDisplayTitle() ?:
-                               $titleObj->getPrefixedText();
+                       $result_array['displaytitle'] = $p_result->getDisplayTitle() !== false
+                               ? $p_result->getDisplayTitle() : $titleObj->getPrefixedText();
                }
 
                if ( isset( $prop['headitems'] ) ) {
@@ -490,12 +484,7 @@ class ApiParse extends ApiBase {
                        }
 
                        $wgParser->startExternalParse( $titleObj, $popts, Parser::OT_PREPROCESS );
-                       $dom = $wgParser->preprocessToDom( $this->content->getNativeData() );
-                       if ( is_callable( [ $dom, 'saveXML' ] ) ) {
-                               $xml = $dom->saveXML();
-                       } else {
-                               $xml = $dom->__toString();
-                       }
+                       $xml = $wgParser->preprocessToDom( $this->content->getNativeData() )->__toString();
                        $result_array['parsetree'] = $xml;
                        $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'parsetree';
                }
@@ -578,7 +567,7 @@ class ApiParse extends ApiBase {
                        } else {
                                $this->content = $page->getContent( Revision::FOR_THIS_USER, $this->getUser() );
                                if ( !$this->content ) {
-                                       $this->dieWithError( [ 'apierror-missingcontent-pageid', $pageId ] );
+                                       $this->dieWithError( [ 'apierror-missingcontent-pageid', $page->getId() ] );
                                }
                        }
                        $this->contentIsDeleted = $isDeleted;
@@ -602,7 +591,7 @@ class ApiParse extends ApiBase {
                        $pout = $page->getParserOutput( $popts, $revId, $suppressCache );
                }
                if ( !$pout ) {
-                       $this->dieWithError( [ 'apierror-nosuchrevid', $revId ?: $page->getLatest() ] );
+                       $this->dieWithError( [ 'apierror-nosuchrevid', $revId ?: $page->getLatest() ] ); // @codeCoverageIgnore
                }
 
                return $pout;
index 9ff4149..326debc 100644 (file)
@@ -235,15 +235,21 @@ class ApiQueryRecentChanges extends ApiQueryGeneratorBase {
                        if ( isset( $show['unpatrolled'] ) ) {
                                // See ChangesList::isUnpatrolled
                                if ( $user->useRCPatrol() ) {
-                                       $this->addWhere( 'rc_patrolled = 0' );
+                                       $this->addWhere( 'rc_patrolled = ' . RecentChange::PRC_UNPATROLLED );
                                } elseif ( $user->useNPPatrol() ) {
-                                       $this->addWhere( 'rc_patrolled = 0' );
+                                       $this->addWhere( 'rc_patrolled = ' . RecentChange::PRC_UNPATROLLED );
                                        $this->addWhereFld( 'rc_type', RC_NEW );
                                }
                        }
 
-                       $this->addWhereIf( 'rc_patrolled != 2', isset( $show['!autopatrolled'] ) );
-                       $this->addWhereIf( 'rc_patrolled = 2', isset( $show['autopatrolled'] ) );
+                       $this->addWhereIf(
+                               'rc_patrolled != ' . RecentChange::PRC_AUTOPATROLLED,
+                               isset( $show['!autopatrolled'] )
+                       );
+                       $this->addWhereIf(
+                               'rc_patrolled = ' . RecentChange::PRC_AUTOPATROLLED,
+                               isset( $show['autopatrolled'] )
+                       );
 
                        // Don't throw log entries out the window here
                        $this->addWhereIf(
@@ -552,9 +558,9 @@ class ApiQueryRecentChanges extends ApiQueryGeneratorBase {
 
                /* Add the patrolled flag */
                if ( $this->fld_patrolled ) {
-                       $vals['patrolled'] = $row->rc_patrolled != 0;
+                       $vals['patrolled'] = $row->rc_patrolled != RecentChange::PRC_UNPATROLLED;
                        $vals['unpatrolled'] = ChangesList::isUnpatrolled( $row, $user );
-                       $vals['autopatrolled'] = $row->rc_patrolled == 2;
+                       $vals['autopatrolled'] = $row->rc_patrolled == RecentChange::PRC_AUTOPATROLLED;
                }
 
                if ( $this->fld_loginfo && $row->rc_type == RC_LOG ) {
index f6bc8cb..12f42ed 100644 (file)
@@ -540,10 +540,22 @@ class ApiQueryContributions extends ApiQueryBase {
 
                        $this->addWhereIf( 'rev_minor_edit = 0', isset( $show['!minor'] ) );
                        $this->addWhereIf( 'rev_minor_edit != 0', isset( $show['minor'] ) );
-                       $this->addWhereIf( 'rc_patrolled = 0', isset( $show['!patrolled'] ) );
-                       $this->addWhereIf( 'rc_patrolled != 0', isset( $show['patrolled'] ) );
-                       $this->addWhereIf( 'rc_patrolled != 2', isset( $show['!autopatrolled'] ) );
-                       $this->addWhereIf( 'rc_patrolled = 2', isset( $show['autopatrolled'] ) );
+                       $this->addWhereIf(
+                               'rc_patrolled = ' . RecentChange::PRC_UNPATROLLED,
+                               isset( $show['!patrolled'] )
+                       );
+                       $this->addWhereIf(
+                               'rc_patrolled != ' . RecentChange::PRC_UNPATROLLED,
+                               isset( $show['patrolled'] )
+                       );
+                       $this->addWhereIf(
+                               'rc_patrolled != ' . RecentChange::PRC_AUTOPATROLLED,
+                               isset( $show['!autopatrolled'] )
+                       );
+                       $this->addWhereIf(
+                               'rc_patrolled = ' . RecentChange::PRC_AUTOPATROLLED,
+                               isset( $show['autopatrolled'] )
+                       );
                        $this->addWhereIf( $idField . ' != page_latest', isset( $show['!top'] ) );
                        $this->addWhereIf( $idField . ' = page_latest', isset( $show['top'] ) );
                        $this->addWhereIf( 'rev_parent_id != 0', isset( $show['!new'] ) );
index 52ad26c..bb09838 100644 (file)
@@ -375,9 +375,9 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase {
 
                /* Add the patrolled flag */
                if ( $this->fld_patrol ) {
-                       $vals['patrolled'] = $recentChangeInfo['rc_patrolled'] != 0;
+                       $vals['patrolled'] = $recentChangeInfo['rc_patrolled'] != RecentChange::PRC_UNPATROLLED;
                        $vals['unpatrolled'] = ChangesList::isUnpatrolled( (object)$recentChangeInfo, $user );
-                       $vals['autopatrolled'] = $recentChangeInfo['rc_patrolled'] == 2;
+                       $vals['autopatrolled'] = $recentChangeInfo['rc_patrolled'] == RecentChange::PRC_AUTOPATROLLED;
                }
 
                if ( $this->fld_loginfo && $recentChangeInfo['rc_type'] == RC_LOG ) {
index 3813aba..56c2c84 100644 (file)
@@ -58,14 +58,16 @@ class ApiUserrights extends ApiBase {
                $params = $this->extractRequestParams();
 
                // Figure out expiry times from the input
-               // $params['expiry'] may not be set in subclasses
+               // $params['expiry'] is not set in CentralAuth's ApiGlobalUserRights subclass
                if ( isset( $params['expiry'] ) ) {
                        $expiry = (array)$params['expiry'];
                } else {
                        $expiry = [ 'infinity' ];
                }
                $add = (array)$params['add'];
-               if ( count( $expiry ) !== count( $add ) ) {
+               if ( !$add ) {
+                       $expiry = [];
+               } elseif ( count( $expiry ) !== count( $add ) ) {
                        if ( count( $expiry ) === 1 ) {
                                $expiry = array_fill( 0, count( $add ), $expiry[0] );
                        } else {
@@ -186,6 +188,7 @@ class ApiUserrights extends ApiBase {
                                ApiBase::PARAM_ISMULTI => true
                        ],
                ];
+               // CentralAuth's ApiGlobalUserRights subclass can't handle expiries
                if ( !$this->getUserRightsPage()->canProcessExpiries() ) {
                        unset( $a['expiry'] );
                }
index b051120..2f41905 100644 (file)
@@ -622,7 +622,7 @@ class RecentChange {
                $dbw->update(
                        'recentchanges',
                        [
-                               'rc_patrolled' => 1
+                               'rc_patrolled' => self::PRC_PATROLLED
                        ],
                        [
                                'rc_id' => $this->getAttribute( 'rc_id' )
@@ -890,7 +890,7 @@ class RecentChange {
                        'rc_last_oldid' => 0,
                        'rc_bot' => $user->isAllowed( 'bot' ) ? (int)$wgRequest->getBool( 'bot', true ) : 0,
                        'rc_ip' => self::checkIPAddress( $ip ),
-                       'rc_patrolled' => $markPatrolled ? 1 : 0,
+                       'rc_patrolled' => $markPatrolled ? self::PRC_PATROLLED : self::PRC_UNPATROLLED,
                        'rc_new' => 0, # obsolete
                        'rc_old_len' => null,
                        'rc_new_len' => null,
@@ -976,7 +976,7 @@ class RecentChange {
                        'rc_last_oldid' => $oldRevId,
                        'rc_bot' => $bot ? 1 : 0,
                        'rc_ip' => self::checkIPAddress( $ip ),
-                       'rc_patrolled' => 1, // Always patrolled, just like log entries
+                       'rc_patrolled' => self::PRC_PATROLLED, // Always patrolled, just like log entries
                        'rc_new' => 0, # obsolete
                        'rc_old_len' => null,
                        'rc_new_len' => null,
index 037a80f..8f57c57 100644 (file)
@@ -542,7 +542,7 @@ class DifferenceEngine extends ContextSource {
                                [
                                        'rc_timestamp' => $db->timestamp( $this->mNewRev->getTimestamp() ),
                                        'rc_this_oldid' => $this->mNewid,
-                                       'rc_patrolled' => 0
+                                       'rc_patrolled' => RecentChange::PRC_UNPATROLLED
                                ],
                                __METHOD__
                        );
index 7fc45eb..0464f07 100644 (file)
@@ -3344,9 +3344,9 @@ class LocalFileMoveBatch {
                        __METHOD__,
                        [ 'FOR UPDATE' ]
                );
-               $oldRowCount = $dbw->selectField(
+               $oldRowCount = $dbw->selectRowCount(
                        'oldimage',
-                       'COUNT(*)',
+                       '*',
                        [ 'oi_name' => $this->oldName ],
                        __METHOD__,
                        [ 'FOR UPDATE' ]
index b68fdae..f01ba63 100644 (file)
@@ -190,7 +190,7 @@ class JobQueueDB extends JobQueue {
                // If the connection is busy with a transaction, then defer the job writes
                // until right before the main round commit step. Any errors that bubble
                // up will rollback the main commit round.
-               // b) mysql/postgres; DB connection is generally a separate CONN_TRX_AUTO handle.
+               // b) mysql/postgres; DB connection is generally a separate CONN_TRX_AUTOCOMMIT handle.
                // No transaction is active nor will be started by writes, so enqueue the jobs
                // now so that any errors will show up immediately as the interface expects. Any
                // errors that bubble up will rollback the main commit round.
@@ -780,7 +780,7 @@ class JobQueueDB extends JobQueue {
                return ( $lb->getServerType( $lb->getWriterIndex() ) !== 'sqlite' )
                        // Keep a separate connection to avoid contention and deadlocks;
                        // However, SQLite has the opposite behavior due to DB-level locking.
-                       ? $lb->getConnectionRef( $index, [], $this->wiki, $lb::CONN_TRX_AUTO )
+                       ? $lb->getConnectionRef( $index, [], $this->wiki, $lb::CONN_TRX_AUTOCOMMIT )
                        // Jobs insertion will be defered until the PRESEND stage to reduce contention.
                        : $lb->getConnectionRef( $index, [], $this->wiki );
        }
index 715f4e4..ae4362d 100644 (file)
@@ -85,6 +85,8 @@ interface ILoadBalancer {
        const DOMAIN_ANY = '';
 
        /** @var int DB handle should have DBO_TRX disabled and the caller will leave it as such */
+       const CONN_TRX_AUTOCOMMIT = 1;
+       /** @var int Alias for CONN_TRX_AUTOCOMMIT for b/c; deprecated since 1.31 */
        const CONN_TRX_AUTO = 1;
 
        /**
@@ -173,11 +175,11 @@ interface ILoadBalancer {
        /**
         * Get a connection handle by server index
         *
-        * The CONN_TRX_AUTO flag is ignored for databases with ATTR_DB_LEVEL_LOCKING
+        * The CONN_TRX_AUTOCOMMIT flag is ignored for databases with ATTR_DB_LEVEL_LOCKING
         * (e.g. sqlite) in order to avoid deadlocks. ILoadBalancer::getServerAttributes()
         * can be used to check such flags beforehand.
         *
-        * If the caller uses $domain or sets CONN_TRX_AUTO in $flags, then it must also
+        * If the caller uses $domain or sets CONN_TRX_AUTOCOMMIT in $flags, then it must also
         * call ILoadBalancer::reuseConnection() on the handle when finished using it.
         * In all other cases, this is not necessary, though not harmful either.
         *
@@ -209,7 +211,7 @@ interface ILoadBalancer {
         *
         * The handle's methods simply wrap those of a Database handle
         *
-        * The CONN_TRX_AUTO flag is ignored for databases with ATTR_DB_LEVEL_LOCKING
+        * The CONN_TRX_AUTOCOMMIT flag is ignored for databases with ATTR_DB_LEVEL_LOCKING
         * (e.g. sqlite) in order to avoid deadlocks. ILoadBalancer::getServerAttributes()
         * can be used to check such flags beforehand.
         *
@@ -218,7 +220,7 @@ interface ILoadBalancer {
         * @param int $i Server index or DB_MASTER/DB_REPLICA
         * @param array|string|bool $groups Query group(s), or false for the generic reader
         * @param string|bool $domain Domain ID, or false for the current domain
-        * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTO)
+        * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTOCOMMIT)
         * @return DBConnRef
         */
        public function getConnectionRef( $i, $groups = [], $domain = false, $flags = 0 );
@@ -228,7 +230,7 @@ interface ILoadBalancer {
         *
         * The handle's methods simply wrap those of a Database handle
         *
-        * The CONN_TRX_AUTO flag is ignored for databases with ATTR_DB_LEVEL_LOCKING
+        * The CONN_TRX_AUTOCOMMIT flag is ignored for databases with ATTR_DB_LEVEL_LOCKING
         * (e.g. sqlite) in order to avoid deadlocks. ILoadBalancer::getServerAttributes()
         * can be used to check such flags beforehand.
         *
@@ -237,7 +239,7 @@ interface ILoadBalancer {
         * @param int $i Server index or DB_MASTER/DB_REPLICA
         * @param array|string|bool $groups Query group(s), or false for the generic reader
         * @param string|bool $domain Domain ID, or false for the current domain
-        * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTO)
+        * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTOCOMMIT)
         * @return DBConnRef
         */
        public function getLazyConnectionRef( $i, $groups = [], $domain = false, $flags = 0 );
@@ -247,7 +249,7 @@ interface ILoadBalancer {
         *
         * The handle's methods simply wrap those of a Database handle
         *
-        * The CONN_TRX_AUTO flag is ignored for databases with ATTR_DB_LEVEL_LOCKING
+        * The CONN_TRX_AUTOCOMMIT flag is ignored for databases with ATTR_DB_LEVEL_LOCKING
         * (e.g. sqlite) in order to avoid deadlocks. ILoadBalancer::getServerAttributes()
         * can be used to check such flags beforehand.
         *
@@ -256,7 +258,7 @@ interface ILoadBalancer {
         * @param int $db Server index or DB_MASTER/DB_REPLICA
         * @param array|string|bool $groups Query group(s), or false for the generic reader
         * @param string|bool $domain Domain ID, or false for the current domain
-        * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTO)
+        * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTOCOMMIT)
         * @return MaintainableDBConnRef
         */
        public function getMaintenanceConnectionRef( $db, $groups = [], $domain = false, $flags = 0 );
@@ -267,11 +269,11 @@ interface ILoadBalancer {
         * The index must be an actual index into the array. If a connection to the server is
         * already open and not considered an "in use" foreign connection, this simply returns it.
         *
-        * Avoid using CONN_TRX_AUTO for databases with ATTR_DB_LEVEL_LOCKING (e.g. sqlite) in
+        * Avoid using CONN_TRX_AUTOCOMMIT for databases with ATTR_DB_LEVEL_LOCKING (e.g. sqlite) in
         * order to avoid deadlocks. ILoadBalancer::getServerAttributes() can be used to check
         * such flags beforehand.
         *
-        * If the caller uses $domain or sets CONN_TRX_AUTO in $flags, then it must also
+        * If the caller uses $domain or sets CONN_TRX_AUTOCOMMIT in $flags, then it must also
         * call ILoadBalancer::reuseConnection() on the handle when finished using it.
         * In all other cases, this is not necessary, though not harmful either.
         *
@@ -279,7 +281,7 @@ interface ILoadBalancer {
         *
         * @param int $i Server index (does not support DB_MASTER/DB_REPLICA)
         * @param string|bool $domain Domain ID, or false for the current domain
-        * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTO)
+        * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTOCOMMIT)
         * @return Database|bool Returns false on errors
         * @throws DBAccessError
         */
index 94acc1e..c587b42 100644 (file)
@@ -575,7 +575,6 @@ class LoadBalancer implements ILoadBalancer {
                        if ( !empty( $connsByServer[$i] ) ) {
                                /** @var IDatabase[] $serverConns */
                                $serverConns = $connsByServer[$i];
-
                                return reset( $serverConns );
                        }
                }
@@ -689,7 +688,7 @@ class LoadBalancer implements ILoadBalancer {
                        $domain = false; // local connection requested
                }
 
-               if ( ( $flags & self::CONN_TRX_AUTO ) === self::CONN_TRX_AUTO ) {
+               if ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) === self::CONN_TRX_AUTOCOMMIT ) {
                        // Assuming all servers are of the same type (or similar), which is overwhelmingly
                        // the case, use the master server information to get the attributes. The information
                        // for $i cannot be used since it might be DB_REPLICA, which might require connection
@@ -700,8 +699,9 @@ class LoadBalancer implements ILoadBalancer {
                                // rows (e.g. FOR UPDATE) or (b) make small commits during a larger transactions
                                // to reduce lock contention. None of these apply for sqlite and using separate
                                // connections just causes self-deadlocks.
-                               $flags &= ~self::CONN_TRX_AUTO;
-                               $this->connLogger->info( __METHOD__ . ': ignoring CONN_TRX_AUTO to avoid deadlocks.' );
+                               $flags &= ~self::CONN_TRX_AUTOCOMMIT;
+                               $this->connLogger->info( __METHOD__ .
+                                       ': ignoring CONN_TRX_AUTOCOMMIT to avoid deadlocks.' );
                        }
                }
 
@@ -859,7 +859,7 @@ class LoadBalancer implements ILoadBalancer {
                // main set of DB connections but rather its own pool since:
                // a) those are usually set to implicitly use transaction rounds via DBO_TRX
                // b) those must support the use of explicit transaction rounds via beginMasterChanges()
-               $autoCommit = ( ( $flags & self::CONN_TRX_AUTO ) == self::CONN_TRX_AUTO );
+               $autoCommit = ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) == self::CONN_TRX_AUTOCOMMIT );
 
                if ( $domain !== false ) {
                        // Connection is to a foreign domain
@@ -937,7 +937,7 @@ class LoadBalancer implements ILoadBalancer {
                $domainInstance = DatabaseDomain::newFromId( $domain );
                $dbName = $domainInstance->getDatabase();
                $prefix = $domainInstance->getTablePrefix();
-               $autoCommit = ( ( $flags & self::CONN_TRX_AUTO ) == self::CONN_TRX_AUTO );
+               $autoCommit = ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) == self::CONN_TRX_AUTOCOMMIT );
 
                if ( $autoCommit ) {
                        $connFreeKey = self::KEY_FOREIGN_FREE_NOROUND;
@@ -1220,7 +1220,7 @@ class LoadBalancer implements ILoadBalancer {
        }
 
        public function closeConnection( IDatabase $conn ) {
-               $serverIndex = $conn->getLBInfo( 'serverIndex' ); // second index level of mConns
+               $serverIndex = $conn->getLBInfo( 'serverIndex' );
                foreach ( $this->conns as $type => $connsByServer ) {
                        if ( !isset( $connsByServer[$serverIndex] ) ) {
                                continue;
index c672ef7..97dadba 100644 (file)
@@ -785,7 +785,7 @@ class ManualLogEntry extends LogEntryBase {
 
                                        // Log the autopatrol if the log entry is patrollable
                                        if ( $this->getIsPatrollable() &&
-                                               $rc->getAttribute( 'rc_patrolled' ) === 2
+                                               $rc->getAttribute( 'rc_patrolled' ) === RecentChange::PRC_AUTOPATROLLED
                                        ) {
                                                PatrolLog::record( $rc, true, $this->getPerformer() );
                                        }
index 6d35658..8ff14ed 100644 (file)
@@ -181,7 +181,7 @@ class SqlBagOStuff extends BagOStuff {
                                $index = $this->replicaOnly ? DB_REPLICA : DB_MASTER;
                                if ( $lb->getServerType( $lb->getWriterIndex() ) !== 'sqlite' ) {
                                        // Keep a separate connection to avoid contention and deadlocks
-                                       $db = $lb->getConnection( $index, [], false, $lb::CONN_TRX_AUTO );
+                                       $db = $lb->getConnection( $index, [], false, $lb::CONN_TRX_AUTOCOMMIT );
                                        // @TODO: Use a blank trx profiler to ignore expections as this is a cache
                                } else {
                                        // However, SQLite has the opposite behavior due to DB-level locking.
index f3860c6..afe266b 100644 (file)
@@ -3288,7 +3288,7 @@ class WikiPage implements Page, IDBAccessObject {
 
                if ( $wgUseRCPatrol ) {
                        // Mark all reverted edits as patrolled
-                       $set['rc_patrolled'] = 1;
+                       $set['rc_patrolled'] = RecentChange::PRC_PATROLLED;
                }
 
                if ( count( $set ) ) {
index eb2cada..2a4acc8 100644 (file)
@@ -87,9 +87,12 @@ abstract class ChangesListSpecialPage extends SpecialPage {
 
        // Same format as filterGroupDefinitions, but for a single group (reviewStatus)
        // that is registered conditionally.
+       private $legacyReviewStatusFilterGroupDefinition;
+
+       // Single filter group registered conditionally
        private $reviewStatusFilterGroupDefinition;
 
-       // Single filter registered conditionally
+       // Single filter group registered conditionally
        private $hideCategorizationFilterDefinition;
 
        /**
@@ -301,7 +304,7 @@ abstract class ChangesListSpecialPage extends SpecialPage {
                                ]
                        ],
 
-                       // reviewStatus (conditional)
+                       // significance (conditional)
 
                        [
                                'name' => 'significance',
@@ -457,17 +460,14 @@ abstract class ChangesListSpecialPage extends SpecialPage {
 
                ];
 
-               $this->reviewStatusFilterGroupDefinition = [
+               $this->legacyReviewStatusFilterGroupDefinition = [
                        [
-                               'name' => 'reviewStatus',
+                               'name' => 'legacyReviewStatus',
                                'title' => 'rcfilters-filtergroup-reviewstatus',
                                'class' => ChangesListBooleanFilterGroup::class,
-                               'priority' => -5,
                                'filters' => [
                                        [
                                                'name' => 'hidepatrolled',
-                                               'label' => 'rcfilters-filter-patrolled-label',
-                                               'description' => 'rcfilters-filter-patrolled-description',
                                                // rcshowhidepatr-show, rcshowhidepatr-hide
                                                // wlshowhidepatr
                                                'showHideSuffix' => 'showhidepatr',
@@ -477,27 +477,75 @@ abstract class ChangesListSpecialPage extends SpecialPage {
                                                ) {
                                                        $conds[] = 'rc_patrolled = 0';
                                                },
-                                               'cssClassSuffix' => 'patrolled',
-                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
-                                                       return $rc->getAttribute( 'rc_patrolled' );
-                                               },
+                                               'isReplacedInStructuredUi' => true,
                                        ],
                                        [
                                                'name' => 'hideunpatrolled',
-                                               'label' => 'rcfilters-filter-unpatrolled-label',
-                                               'description' => 'rcfilters-filter-unpatrolled-description',
                                                'default' => false,
                                                'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
                                                        &$query_options, &$join_conds
                                                ) {
                                                        $conds[] = 'rc_patrolled != 0';
                                                },
-                                               'cssClassSuffix' => 'unpatrolled',
+                                               'isReplacedInStructuredUi' => true,
+                                       ],
+                               ],
+                       ]
+               ];
+
+               $this->reviewStatusFilterGroupDefinition = [
+                       [
+                               'name' => 'reviewStatus',
+                               'title' => 'rcfilters-filtergroup-reviewstatus',
+                               'class' => ChangesListStringOptionsFilterGroup::class,
+                               'isFullCoverage' => true,
+                               'priority' => -5,
+                               'filters' => [
+                                       [
+                                               'name' => 'unpatrolled',
+                                               'label' => 'rcfilters-filter-reviewstatus-unpatrolled-label',
+                                               'description' => 'rcfilters-filter-reviewstatus-unpatrolled-description',
+                                               'cssClassSuffix' => 'reviewstatus-unpatrolled',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED;
+                                               },
+                                       ],
+                                       [
+                                               'name' => 'manual',
+                                               'label' => 'rcfilters-filter-reviewstatus-manual-label',
+                                               'description' => 'rcfilters-filter-reviewstatus-manual-description',
+                                               'cssClassSuffix' => 'reviewstatus-manual',
                                                'isRowApplicableCallable' => function ( $ctx, $rc ) {
-                                                       return !$rc->getAttribute( 'rc_patrolled' );
+                                                       return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_PATROLLED;
+                                               },
+                                       ],
+                                       [
+                                               'name' => 'auto',
+                                               'label' => 'rcfilters-filter-reviewstatus-auto-label',
+                                               'description' => 'rcfilters-filter-reviewstatus-auto-description',
+                                               'cssClassSuffix' => 'reviewstatus-auto',
+                                               'isRowApplicableCallable' => function ( $ctx, $rc ) {
+                                                       return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_AUTOPATROLLED;
                                                },
                                        ],
                                ],
+                               'default' => ChangesListStringOptionsFilterGroup::NONE,
+                               'queryCallable' => function ( $specialPageClassName, $ctx, $dbr,
+                                       &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selected
+                               ) {
+                                       if ( $selected === [] ) {
+                                               return;
+                                       }
+                                       $rcPatrolledValues = [
+                                               'unpatrolled' => RecentChange::PRC_UNPATROLLED,
+                                               'manual' => RecentChange::PRC_PATROLLED,
+                                               'auto' => RecentChange::PRC_AUTOPATROLLED,
+                                       ];
+                                       // e.g. rc_patrolled IN (0, 2)
+                                       $conds['rc_patrolled'] = array_map( function ( $s ) use ( $rcPatrolledValues ) {
+                                               return $rcPatrolledValues[ $s ];
+                                       }, $selected );
+                               }
                        ]
                ];
 
@@ -910,6 +958,7 @@ abstract class ChangesListSpecialPage extends SpecialPage {
                // information to all users just because the user that saves the edit can
                // patrol or is logged in)
                if ( !$this->including() && $this->getUser()->useRCPatrol() ) {
+                       $this->registerFiltersFromDefinitions( $this->legacyReviewStatusFilterGroupDefinition );
                        $this->registerFiltersFromDefinitions( $this->reviewStatusFilterGroupDefinition );
                }
 
@@ -1339,7 +1388,7 @@ abstract class ChangesListSpecialPage extends SpecialPage {
        }
 
        /**
-        * Replace old options 'hideanons' or 'hideliu' with structured UI equivalent
+        * Replace old options with their structured UI equivalents
         *
         * @param FormOptions $opts
         * @return bool True if the change was made
@@ -1349,21 +1398,40 @@ abstract class ChangesListSpecialPage extends SpecialPage {
                        return false;
                }
 
+               $changed = false;
+
                // At this point 'hideanons' and 'hideliu' cannot be both true,
                // because fixBackwardsCompatibilityOptions resets (at least) 'hideanons' in such case
                if ( $opts[ 'hideanons' ] ) {
                        $opts->reset( 'hideanons' );
                        $opts[ 'userExpLevel' ] = 'registered';
-                       return true;
+                       $changed = true;
                }
 
                if ( $opts[ 'hideliu' ] ) {
                        $opts->reset( 'hideliu' );
                        $opts[ 'userExpLevel' ] = 'unregistered';
-                       return true;
+                       $changed = true;
                }
 
-               return false;
+               if ( $this->getFilterGroup( 'legacyReviewStatus' ) ) {
+                       if ( $opts[ 'hidepatrolled' ] ) {
+                               $opts->reset( 'hidepatrolled' );
+                               $opts[ 'reviewStatus' ] = 'unpatrolled';
+                               $changed = true;
+                       }
+
+                       if ( $opts[ 'hideunpatrolled' ] ) {
+                               $opts->reset( 'hideunpatrolled' );
+                               $opts[ 'reviewStatus' ] = implode(
+                                       ChangesListStringOptionsFilterGroup::SEPARATOR,
+                                       [ 'manual', 'auto' ]
+                               );
+                               $changed = true;
+                       }
+               }
+
+               return $changed;
        }
 
        /**
index cb2f420..bfef5e0 100644 (file)
@@ -208,8 +208,12 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
                $reviewStatus = $this->getFilterGroup( 'reviewStatus' );
                if ( $reviewStatus !== null ) {
                        // Conditional on feature being available and rights
-                       $hidePatrolled = $reviewStatus->getFilter( 'hidepatrolled' );
-                       $hidePatrolled->setDefault( $user->getBoolOption( 'hidepatrolled' ) );
+                       if ( $user->getBoolOption( 'hidepatrolled' ) ) {
+                               $reviewStatus->setDefault( 'unpatrolled' );
+                               $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' );
+                               $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' );
+                               $legacyHidePatrolled->setDefault( true );
+                       }
                }
 
                $changeType = $this->getFilterGroup( 'changeType' );
index 3fe6c1e..dda1dac 100644 (file)
@@ -264,8 +264,12 @@ class SpecialWatchlist extends ChangesListSpecialPage {
                $reviewStatus = $this->getFilterGroup( 'reviewStatus' );
                if ( $reviewStatus !== null ) {
                        // Conditional on feature being available and rights
-                       $hidePatrolled = $reviewStatus->getFilter( 'hidepatrolled' );
-                       $hidePatrolled->setDefault( $user->getBoolOption( 'watchlisthidepatrolled' ) );
+                       if ( $user->getBoolOption( 'watchlisthidepatrolled' ) ) {
+                               $reviewStatus->setDefault( 'unpatrolled' );
+                               $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' );
+                               $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' );
+                               $legacyHidePatrolled->setDefault( true );
+                       }
                }
 
                $authorship = $this->getFilterGroup( 'authorship' );
index 3e6b212..ea395f4 100644 (file)
@@ -3084,7 +3084,7 @@ class User implements IDBAccessObject, UserIdentity {
         * Get the user's current setting for a given option.
         *
         * @param string $oname The option to check
-        * @param string $defaultOverride A default value returned if the option does not exist
+        * @param string|array $defaultOverride A default value returned if the option does not exist
         * @param bool $ignoreHidden Whether to ignore the effects of $wgHiddenPrefs
         * @return string|array|int|null User's current value for the option
         * @see getBoolOption()
index 4bdf97e..5cfad4b 100644 (file)
        "rcfilters-filter-humans-label": "Human (not bot)",
        "rcfilters-filter-humans-description": "Edits made by human editors.",
        "rcfilters-filtergroup-reviewstatus": "Review status",
-       "rcfilters-filter-patrolled-label": "Patrolled",
-       "rcfilters-filter-patrolled-description": "Edits marked as patrolled.",
-       "rcfilters-filter-unpatrolled-label": "Unpatrolled",
-       "rcfilters-filter-unpatrolled-description": "Edits not marked as patrolled.",
+       "rcfilters-filter-reviewstatus-unpatrolled-description": "Edits not manually or automatically marked as patrolled.",
+       "rcfilters-filter-reviewstatus-unpatrolled-label": "Unpatrolled",
+       "rcfilters-filter-reviewstatus-manual-description": "Edits manually marked as patrolled.",
+       "rcfilters-filter-reviewstatus-manual-label": "Manually patrolled",
+       "rcfilters-filter-reviewstatus-auto-description": "Edits by advanced users whose work is automatically marked as patrolled.",
+       "rcfilters-filter-reviewstatus-auto-label": "Autopatrolled",
        "rcfilters-filtergroup-significance": "Significance",
        "rcfilters-filter-minor-label": "Minor edits",
        "rcfilters-filter-minor-description": "Edits the author labeled as minor.",
index 6e54765..374dd90 100644 (file)
        "rcfilters-filter-humans-label": "Label for the filter for showing edits made by human editors.",
        "rcfilters-filter-humans-description": "Description for the filter for showing edits made by human editors.",
        "rcfilters-filtergroup-reviewstatus": "Title for the filter group about review status (in core this is whether it's been patrolled)",
-       "rcfilters-filter-patrolled-label": "Label for the filter for showing patrolled edits",
-       "rcfilters-filter-patrolled-description": "Label for the filter showing patrolled edits",
-       "rcfilters-filter-unpatrolled-label": "Label for the filter for showing unpatrolled edits",
-       "rcfilters-filter-unpatrolled-description": "Description for the filter for showing unpatrolled edits",
+       "rcfilters-filter-reviewstatus-manual-description": "Description for the filter showing manually patrolled edits",
+       "rcfilters-filter-reviewstatus-manual-label": "Label for the filter showing manually patrolled edits",
+       "rcfilters-filter-reviewstatus-auto-description": "Description for the filter showing automatically patrolled edits",
+       "rcfilters-filter-reviewstatus-auto-label": "Label for the filter showing automatically patrolled edits",
+       "rcfilters-filter-reviewstatus-unpatrolled-description": "Description for the filter for showing unpatrolled edits",
+       "rcfilters-filter-reviewstatus-unpatrolled-label": "Label for the filter for showing unpatrolled edits",
        "rcfilters-filtergroup-significance": "Title for the filter group for edit significance.\n{{Identical|Significance}}",
        "rcfilters-filter-minor-label": "Label for the filter for showing edits marked as minor.",
        "rcfilters-filter-minor-description": "Description for the filter for showing edits marked as minor.",
index 9685177..13fee9c 100644 (file)
@@ -35,6 +35,10 @@ use Wikimedia\Rdbms\DBReplicationWaitError;
 
 // Define this so scripts can easily find doMaintenance.php
 define( 'RUN_MAINTENANCE_IF_MAIN', __DIR__ . '/doMaintenance.php' );
+
+/**
+ * @deprecated since 1.31
+ */
 define( 'DO_MAINTENANCE', RUN_MAINTENANCE_IF_MAIN ); // original name, harmless
 
 $maintClass = false;
@@ -1211,6 +1215,12 @@ abstract class Maintenance {
                        }
                        define( 'MW_DB', $bits[0] );
                        define( 'MW_PREFIX', $bits[1] );
+               } elseif ( isset( $this->mOptions['server'] ) ) {
+                       // Provide the option for site admins to detect and configure
+                       // multiple wikis based on server names. This offers --server
+                       // as alternative to --wiki.
+                       // See https://www.mediawiki.org/wiki/Manual:Wiki_family
+                       $_SERVER['SERVER_NAME'] = $this->mOptions['server'];
                }
 
                if ( !is_readable( $settingsFile ) ) {
@@ -1218,9 +1228,6 @@ abstract class Maintenance {
                                "must exist and be readable in the source directory.\n" .
                                "Use --conf to specify it." );
                }
-               if ( isset( $this->mOptions['server'] ) ) {
-                       $_SERVER['SERVER_NAME'] = $this->mOptions['server'];
-               }
                $wgCommandLineMode = true;
 
                return $settingsFile;
index ea5835d..2468c71 100644 (file)
        min-width: 20em;
 }
 
+/* Hide empty live-log textarea */
+#config-live-log textarea:empty {
+       display: none;
+}
+
 /* tooltip styles */
 .config-help-field-hint {
        display: none;
index 2e5efba..693cd7f 100644 (file)
@@ -6,6 +6,12 @@
        -ms-user-select: none;
        user-select: none;
 }
+.mw-collapsible-toggle:before {
+       content: '[';
+}
+.mw-collapsible-toggle:after {
+       content: ']';
+}
 /* Align the toggle based on the direction of the content language */
 /* @noflip */
 .mw-content-ltr .mw-collapsible-toggle,
index aa76d6d..7826bab 100644 (file)
@@ -8,7 +8,6 @@
  * @class jQuery.plugin.makeCollapsible
  */
 ( function ( $, mw ) {
-
        /**
         * Handler for a click on a collapsible toggler.
         *
                                                role: 'button',
                                                tabindex: 0
                                        } )
-                                       .prepend( '<span>[</span>' )
-                                       .append( '<span>]</span>' )
                                        .on( 'click.mw-collapsible keypress.mw-collapsible', actionHandler );
                        };
 
index 7d49a09..76d4bfb 100644 (file)
@@ -51,6 +51,9 @@
                // Parent constructor
                mw.widgets.TitleOptionWidget.parent.call( this, config );
 
+               // Remove check icon
+               this.checkIcon.$element.remove();
+
                // Initialization
                this.$label.attr( 'href', config.url );
                this.$element.addClass( 'mw-widget-titleOptionWidget' );
index 28335ec..844a43f 100644 (file)
@@ -615,9 +615,13 @@ class ParserTestRunner {
                        return false;
                } );// hooks::register
 
+               // Reset the service in case any other tests already cached some prefixes.
+               MediaWikiServices::getInstance()->resetServiceForTesting( 'InterwikiLookup' );
+
                return function () {
                        // Tear down
                        Hooks::clear( 'InterwikiLoadPrefix' );
+                       MediaWikiServices::getInstance()->resetServiceForTesting( 'InterwikiLookup' );
                };
        }
 
index 2e93f36..03588ae 100644 (file)
@@ -178,6 +178,9 @@ class MediaWikiServicesTest extends MediaWikiTestCase {
 
                MediaWikiServices::forceGlobalInstance( $oldServices );
                $newServices->destroy();
+
+               // No exception was thrown, avoid being risky
+               $this->assertTrue( true );
        }
 
        public function testResetChildProcessServices() {
index 4818b49..0c178ca 100644 (file)
@@ -107,4 +107,19 @@ class TestUserRegistry {
        public static function clear() {
                self::$testUsers = [];
        }
+
+       /**
+        * @todo It would be nice if this were a non-static method of TestUser
+        * instead, but that doesn't seem possible without friends?
+        *
+        * @return bool True if it's safe to modify the user
+        */
+       public static function isMutable( User $user ) {
+               foreach ( self::$testUsers as $key => $testUser ) {
+                       if ( $user === $testUser->getUser() ) {
+                               return false;
+                       }
+               }
+               return true;
+       }
 }
index 8ffe4fc..d17334b 100644 (file)
@@ -46,7 +46,7 @@ class ApiMainTest extends ApiTestCase {
         */
        private function getNonInternalApiMain( array $requestData, array $headers = [] ) {
                $req = $this->getMockBuilder( WebRequest::class )
-                       ->setMethods( [ 'response', 'getIP' ] )
+                       ->setMethods( [ 'response', 'getRawIP' ] )
                        ->getMock();
                $response = new FauxResponse();
                $req->method( 'response' )->willReturn( $response );
index e236437..a04271f 100644 (file)
@@ -13,48 +13,136 @@ class ApiParseTest extends ApiTestCase {
        protected static $revIds = [];
 
        public function addDBDataOnce() {
-               $user = static::getTestSysop()->getUser();
                $title = Title::newFromText( __CLASS__ );
-               $page = WikiPage::factory( $title );
 
-               $status = $page->doEditContent(
-                       ContentHandler::makeContent( 'Test for revdel', $title, CONTENT_MODEL_WIKITEXT ),
-                       __METHOD__ . ' Test for revdel', 0, false, $user
-               );
-               if ( !$status->isOK() ) {
-                       $this->fail( "Failed to create $title: " . $status->getWikiText( false, false, 'en' ) );
-               }
+               $status = $this->editPage( __CLASS__, 'Test for revdel' );
                self::$pageId = $status->value['revision']->getPage();
                self::$revIds['revdel'] = $status->value['revision']->getId();
 
-               $status = $page->doEditContent(
-                       ContentHandler::makeContent( 'Test for oldid', $title, CONTENT_MODEL_WIKITEXT ),
-                       __METHOD__ . ' Test for oldid', 0, false, $user
-               );
-               if ( !$status->isOK() ) {
-                       $this->fail( "Failed to edit $title: " . $status->getWikiText( false, false, 'en' ) );
-               }
+               $status = $this->editPage( __CLASS__, 'Test for suppressed' );
+               self::$revIds['suppressed'] = $status->value['revision']->getId();
+
+               $status = $this->editPage( __CLASS__, 'Test for oldid' );
                self::$revIds['oldid'] = $status->value['revision']->getId();
 
-               $status = $page->doEditContent(
-                       ContentHandler::makeContent( 'Test for latest', $title, CONTENT_MODEL_WIKITEXT ),
-                       __METHOD__ . ' Test for latest', 0, false, $user
+               $status = $this->editPage( __CLASS__, 'Test for latest' );
+               self::$revIds['latest'] = $status->value['revision']->getId();
+
+               $this->revisionDelete( self::$revIds['revdel'] );
+               $this->revisionDelete(
+                       self::$revIds['suppressed'],
+                       [ Revision::DELETED_TEXT => 1, Revision::DELETED_RESTRICTED => 1 ]
                );
-               if ( !$status->isOK() ) {
-                       $this->fail( "Failed to edit $title: " . $status->getWikiText( false, false, 'en' ) );
+
+               Title::clearCaches(); // Otherwise it has the wrong latest revision for some reason
+       }
+
+       /**
+        * Assert that the given result of calling $this->doApiRequest() with
+        * action=parse resulted in $html, accounting for the boilerplate that the
+        * parser adds around the parsed page.  Also asserts that warnings match
+        * the provided $warning.
+        *
+        * @param string $html Expected HTML
+        * @param array $res Returned from doApiRequest()
+        * @param string|null $warnings Exact value of expected warnings, null for
+        *   no warnings
+        */
+       protected function assertParsedTo( $expected, array $res, $warnings = null ) {
+               $this->doAssertParsedTo( $expected, $res, $warnings, [ $this, 'assertSame' ] );
+       }
+
+       /**
+        * Same as above, but asserts that the HTML matches a regexp instead of a
+        * literal string match.
+        *
+        * @param string $html Expected HTML
+        * @param array $res Returned from doApiRequest()
+        * @param string|null $warnings Exact value of expected warnings, null for
+        *   no warnings
+        */
+       protected function assertParsedToRegExp( $expected, array $res, $warnings = null ) {
+               $this->doAssertParsedTo( $expected, $res, $warnings, [ $this, 'assertRegExp' ] );
+       }
+
+       private function doAssertParsedTo( $expected, array $res, $warnings, callable $callback ) {
+               $html = $res[0]['parse']['text'];
+
+               $expectedStart = '<div class="mw-parser-output">';
+               $this->assertSame( $expectedStart, substr( $html, 0, strlen( $expectedStart ) ) );
+
+               $html = substr( $html, strlen( $expectedStart ) );
+
+               if ( $res[1]->getBool( 'disablelimitreport' ) ) {
+                       $expectedEnd = "</div>";
+                       $this->assertSame( $expectedEnd, substr( $html, -strlen( $expectedEnd ) ) );
+
+                       $html = substr( $html, 0, strlen( $html ) - strlen( $expectedEnd ) );
+               } else {
+                       $expectedEnd = '#\n<!-- \nNewPP limit report\n(?>.+?\n-->)\n' .
+                               '<!--\nTransclusion expansion time report \(%,ms,calls,template\)\n(?>.*?\n-->)\n' .
+                               '</div>(\n<!-- Saved in parser cache (?>.*?\n -->)\n)?$#s';
+                       $this->assertRegExp( $expectedEnd, $html );
+
+                       $html = preg_replace( $expectedEnd, '', $html );
                }
-               self::$revIds['latest'] = $status->value['revision']->getId();
 
-               RevisionDeleter::createList(
-                       'revision', RequestContext::getMain(), $title, [ self::$revIds['revdel'] ]
-               )->setVisibility( [
-                       'value' => [
-                               Revision::DELETED_TEXT => 1,
+               call_user_func( $callback, $expected, $html );
+
+               if ( $warnings === null ) {
+                       $this->assertCount( 1, $res[0] );
+               } else {
+                       $this->assertCount( 2, $res[0] );
+                       // This deliberately fails if there are extra warnings
+                       $this->assertSame( [ 'parse' => [ 'warnings' => $warnings ] ], $res[0]['warnings'] );
+               }
+       }
+
+       /**
+        * Set up an interwiki entry for testing.
+        */
+       protected function setupInterwiki() {
+               $dbw = wfGetDB( DB_MASTER );
+               $dbw->insert(
+                       'interwiki',
+                       [
+                               'iw_prefix' => 'madeuplanguage',
+                               'iw_url' => "https://example.com/wiki/$1",
+                               'iw_api' => '',
+                               'iw_wikiid' => '',
+                               'iw_local' => false,
                        ],
-                       'comment' => 'Test for revdel',
-               ] );
+                       __METHOD__,
+                       'IGNORE'
+               );
 
-               Title::clearCaches(); // Otherwise it has the wrong latest revision for some reason
+               $this->setMwGlobals( 'wgExtraInterlanguageLinkPrefixes', [ 'madeuplanguage' ] );
+               $this->tablesUsed[] = 'interwiki';
+       }
+
+       /**
+        * Set up a skin for testing.
+        *
+        * @todo Should this code be in MediaWikiTestCase or something?
+        */
+       protected function setupSkin() {
+               $factory = new SkinFactory();
+               $factory->register( 'testing', 'Testing', function () {
+                       $skin = $this->getMockBuilder( SkinFallback::class )
+                               ->setMethods( [ 'getDefaultModules', 'setupSkinUserCss' ] )
+                               ->getMock();
+                       $skin->expects( $this->once() )->method( 'getDefaultModules' )
+                               ->willReturn( [
+                                       'core' => [ 'foo', 'bar' ],
+                                       'content' => [ 'baz' ]
+                               ] );
+                       $skin->expects( $this->once() )->method( 'setupSkinUserCss' )
+                               ->will( $this->returnCallback( function ( OutputPage $out ) {
+                                       $out->addModuleStyles( 'foo.styles' );
+                               } ) );
+                       return $skin;
+               } );
+               $this->setService( 'SkinFactory', $factory );
        }
 
        public function testParseByName() {
@@ -62,14 +150,14 @@ class ApiParseTest extends ApiTestCase {
                        'action' => 'parse',
                        'page' => __CLASS__,
                ] );
-               $this->assertContains( 'Test for latest', $res[0]['parse']['text'] );
+               $this->assertParsedTo( "<p>Test for latest\n</p>", $res );
 
                $res = $this->doApiRequest( [
                        'action' => 'parse',
                        'page' => __CLASS__,
                        'disablelimitreport' => 1,
                ] );
-               $this->assertContains( 'Test for latest', $res[0]['parse']['text'] );
+               $this->assertParsedTo( "<p>Test for latest\n</p>", $res );
        }
 
        public function testParseById() {
@@ -77,7 +165,7 @@ class ApiParseTest extends ApiTestCase {
                        'action' => 'parse',
                        'pageid' => self::$pageId,
                ] );
-               $this->assertContains( 'Test for latest', $res[0]['parse']['text'] );
+               $this->assertParsedTo( "<p>Test for latest\n</p>", $res );
        }
 
        public function testParseByOldId() {
@@ -85,36 +173,46 @@ class ApiParseTest extends ApiTestCase {
                        'action' => 'parse',
                        'oldid' => self::$revIds['oldid'],
                ] );
-               $this->assertContains( 'Test for oldid', $res[0]['parse']['text'] );
+               $this->assertParsedTo( "<p>Test for oldid\n</p>", $res );
                $this->assertArrayNotHasKey( 'textdeleted', $res[0]['parse'] );
                $this->assertArrayNotHasKey( 'textsuppressed', $res[0]['parse'] );
        }
 
-       public function testParseRevDel() {
-               $user = static::getTestUser()->getUser();
-               $sysop = static::getTestSysop()->getUser();
-
-               try {
-                       $this->doApiRequest( [
-                               'action' => 'parse',
-                               'oldid' => self::$revIds['revdel'],
-                       ], null, null, $user );
-                       $this->fail( "API did not return an error as expected" );
-               } catch ( ApiUsageException $ex ) {
-                       $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'permissiondenied' ),
-                               "API failed with error 'permissiondenied'" );
-               }
-
+       public function testRevDel() {
                $res = $this->doApiRequest( [
                        'action' => 'parse',
                        'oldid' => self::$revIds['revdel'],
-               ], null, null, $sysop );
-               $this->assertContains( 'Test for revdel', $res[0]['parse']['text'] );
+               ] );
+
+               $this->assertParsedTo( "<p>Test for revdel\n</p>", $res );
                $this->assertArrayHasKey( 'textdeleted', $res[0]['parse'] );
                $this->assertArrayNotHasKey( 'textsuppressed', $res[0]['parse'] );
        }
 
-       public function testParseNonexistentPage() {
+       public function testRevDelNoPermission() {
+               $this->setExpectedException( ApiUsageException::class,
+                       "You don't have permission to view deleted revision text." );
+
+               $this->doApiRequest( [
+                       'action' => 'parse',
+                       'oldid' => self::$revIds['revdel'],
+               ], null, null, static::getTestUser()->getUser() );
+       }
+
+       public function testSuppressed() {
+               $this->setGroupPermissions( 'sysop', 'viewsuppressed', true );
+
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'oldid' => self::$revIds['suppressed']
+               ] );
+
+               $this->assertParsedTo( "<p>Test for suppressed\n</p>", $res );
+               $this->assertArrayHasKey( 'textsuppressed', $res[0]['parse'] );
+               $this->assertArrayHasKey( 'textdeleted', $res[0]['parse'] );
+       }
+
+       public function testNonexistentPage() {
                try {
                        $this->doApiRequest( [
                                'action' => 'parse',
@@ -130,24 +228,446 @@ class ApiParseTest extends ApiTestCase {
                }
        }
 
-       public function testSkinModules() {
-               $factory = new SkinFactory();
-               $factory->register( 'testing', 'Testing', function () {
-                       $skin = $this->getMockBuilder( SkinFallback::class )
-                               ->setMethods( [ 'getDefaultModules', 'setupSkinUserCss' ] )
-                               ->getMock();
-                       $skin->expects( $this->once() )->method( 'getDefaultModules' )
-                               ->willReturn( [
-                                       'core' => [ 'foo', 'bar' ],
-                                       'content' => [ 'baz' ]
-                               ] );
-                       $skin->expects( $this->once() )->method( 'setupSkinUserCss' )
-                               ->will( $this->returnCallback( function ( OutputPage $out ) {
-                                       $out->addModuleStyles( 'foo.styles' );
-                               } ) );
-                       return $skin;
-               } );
-               $this->setService( 'SkinFactory', $factory );
+       public function testTitleProvided() {
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'title' => 'Some interesting page',
+                       'text' => '{{PAGENAME}} has attracted my attention',
+               ] );
+
+               $this->assertParsedTo( "<p>Some interesting page has attracted my attention\n</p>", $res );
+       }
+
+       public function testSection() {
+               $name = ucfirst( __FUNCTION__ );
+
+               $this->editPage( $name,
+                       "Intro\n\n== Section 1 ==\n\nContent 1\n\n== Section 2 ==\n\nContent 2" );
+
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'page' => $name,
+                       'section' => 1,
+               ] );
+
+               $this->assertParsedToRegExp( '!<h2>.*Section 1.*</h2>\n<p>Content 1\n</p>!', $res );
+       }
+
+       public function testInvalidSection() {
+               $this->setExpectedException( ApiUsageException::class,
+                       'The "section" parameter must be a valid section ID or "new".' );
+
+               $this->doApiRequest( [
+                       'action' => 'parse',
+                       'section' => 'T-new',
+               ] );
+       }
+
+       public function testSectionNoContent() {
+               $name = ucfirst( __FUNCTION__ );
+
+               $status = $this->editPage( $name,
+                       "Intro\n\n== Section 1 ==\n\nContent 1\n\n== Section 2 ==\n\nContent 2" );
+
+               $this->setExpectedException( ApiUsageException::class,
+                       "Missing content for page ID {$status->value['revision']->getPage()}." );
+
+               $this->db->delete( 'revision', [ 'rev_id' => $status->value['revision']->getId() ] );
+
+               // Suppress warning in WikiPage::getContentModel
+               Wikimedia\suppressWarnings();
+               try {
+                       $this->doApiRequest( [
+                               'action' => 'parse',
+                               'page' => $name,
+                               'section' => 1,
+                       ] );
+               } finally {
+                       Wikimedia\restoreWarnings();
+               }
+       }
+
+       public function testNewSectionWithPage() {
+               $this->setExpectedException( ApiUsageException::class,
+                       '"section=new" cannot be combined with the "oldid", "pageid" or "page" ' .
+                       'parameters. Please use "title" and "text".' );
+
+               $this->doApiRequest( [
+                       'action' => 'parse',
+                       'page' => __CLASS__,
+                       'section' => 'new',
+               ] );
+       }
+
+       public function testNonexistentOldId() {
+               $this->setExpectedException( ApiUsageException::class,
+                       'There is no revision with ID 2147483647.' );
+
+               $this->doApiRequest( [
+                       'action' => 'parse',
+                       'oldid' => pow( 2, 31 ) - 1,
+               ] );
+       }
+
+       public function testUnfollowedRedirect() {
+               $name = ucfirst( __FUNCTION__ );
+
+               $this->editPage( $name, "#REDIRECT [[$name 2]]" );
+               $this->editPage( "$name 2", "Some ''text''" );
+
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'page' => $name,
+               ] );
+
+               // Can't use assertParsedTo because the parser output is different for
+               // redirects
+               $this->assertRegExp( "/Redirect to:.*$name 2/", $res[0]['parse']['text'] );
+               $this->assertArrayNotHasKey( 'warnings', $res[0] );
+       }
+
+       public function testFollowedRedirect() {
+               $name = ucfirst( __FUNCTION__ );
+
+               $this->editPage( $name, "#REDIRECT [[$name 2]]" );
+               $this->editPage( "$name 2", "Some ''text''" );
+
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'page' => $name,
+                       'redirects' => true,
+               ] );
+
+               $this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res );
+       }
+
+       public function testFollowedRedirectById() {
+               $name = ucfirst( __FUNCTION__ );
+
+               $id = $this->editPage( $name, "#REDIRECT [[$name 2]]" )->value['revision']->getPage();
+               $this->editPage( "$name 2", "Some ''text''" );
+
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'pageid' => $id,
+                       'redirects' => true,
+               ] );
+
+               $this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res );
+       }
+
+       public function testInvalidTitle() {
+               $this->setExpectedException( ApiUsageException::class, 'Bad title "|".' );
+
+               $this->doApiRequest( [
+                       'action' => 'parse',
+                       'title' => '|',
+               ] );
+       }
+
+       public function testTitleWithNonexistentRevId() {
+               $this->setExpectedException( ApiUsageException::class,
+                       'There is no revision with ID 2147483647.' );
+
+               $this->doApiRequest( [
+                       'action' => 'parse',
+                       'title' => __CLASS__,
+                       'revid' => pow( 2, 31 ) - 1,
+               ] );
+       }
+
+       public function testTitleWithNonMatchingRevId() {
+               $name = ucfirst( __FUNCTION__ );
+
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'title' => $name,
+                       'revid' => self::$revIds['latest'],
+                       'text' => 'Some text',
+               ] );
+
+               $this->assertParsedTo( "<p>Some text\n</p>", $res,
+                       'r' . self::$revIds['latest'] . " is not a revision of $name." );
+       }
+
+       public function testRevId() {
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'revid' => self::$revIds['latest'],
+                       'text' => 'My revid is {{REVISIONID}}!',
+               ] );
+
+               $this->assertParsedTo( "<p>My revid is " . self::$revIds['latest'] . "!\n</p>", $res );
+       }
+
+       public function testTitleNoText() {
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'title' => 'Special:AllPages',
+               ] );
+
+               $this->assertParsedTo( '', $res,
+                       '"title" used without "text", and parsed page properties were requested. ' .
+                               'Did you mean to use "page" instead of "title"?' );
+       }
+
+       public function testRevidNoText() {
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'revid' => self::$revIds['latest'],
+               ] );
+
+               $this->assertParsedTo( '', $res,
+                       '"revid" used without "text", and parsed page properties were requested. ' .
+                               'Did you mean to use "oldid" instead of "revid"?' );
+       }
+
+       public function testTextNoContentModel() {
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'text' => "Some ''text''",
+               ] );
+
+               $this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res,
+                       'No "title" or "contentmodel" was given, assuming wikitext.' );
+       }
+
+       public function testSerializationError() {
+               $this->setExpectedException( APIUsageException::class,
+                       'Content serialization failed: Could not unserialize content' );
+
+               $this->mergeMwGlobalArrayValue( 'wgContentHandlers',
+                       [ 'testing-serialize-error' => 'DummySerializeErrorContentHandler' ] );
+
+               $this->doApiRequest( [
+                       'action' => 'parse',
+                       'text' => "Some ''text''",
+                       'contentmodel' => 'testing-serialize-error',
+               ] );
+       }
+
+       public function testNewSection() {
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'title' => __CLASS__,
+                       'section' => 'new',
+                       'sectiontitle' => 'Title',
+                       'text' => 'Content',
+               ] );
+
+               $this->assertParsedToRegExp( '!<h2>.*Title.*</h2>\n<p>Content\n</p>!', $res );
+       }
+
+       public function testExistingSection() {
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'title' => __CLASS__,
+                       'section' => 1,
+                       'text' => "Intro\n\n== Section 1 ==\n\nContent\n\n== Section 2 ==\n\nMore content",
+               ] );
+
+               $this->assertParsedToRegExp( '!<h2>.*Section 1.*</h2>\n<p>Content\n</p>!', $res );
+       }
+
+       public function testNoPst() {
+               $name = ucfirst( __FUNCTION__ );
+
+               $this->editPage( "Template:$name", "Template ''text''" );
+
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'text' => "{{subst:$name}}",
+                       'contentmodel' => 'wikitext',
+               ] );
+
+               $this->assertParsedTo( "<p>{{subst:$name}}\n</p>", $res );
+       }
+
+       public function testPst() {
+               $name = ucfirst( __FUNCTION__ );
+
+               $this->editPage( "Template:$name", "Template ''text''" );
+
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'pst' => '',
+                       'text' => "{{subst:$name}}",
+                       'contentmodel' => 'wikitext',
+                       'prop' => 'text|wikitext',
+               ] );
+
+               $this->assertParsedTo( "<p>Template <i>text</i>\n</p>", $res );
+               $this->assertSame( "{{subst:$name}}", $res[0]['parse']['wikitext'] );
+       }
+
+       public function testOnlyPst() {
+               $name = ucfirst( __FUNCTION__ );
+
+               $this->editPage( "Template:$name", "Template ''text''" );
+
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'onlypst' => '',
+                       'text' => "{{subst:$name}}",
+                       'contentmodel' => 'wikitext',
+                       'prop' => 'text|wikitext',
+                       'summary' => 'Summary',
+               ] );
+
+               $this->assertSame(
+                       [ 'parse' => [
+                               'text' => "Template ''text''",
+                               'wikitext' => "{{subst:$name}}",
+                               'parsedsummary' => 'Summary',
+                       ] ],
+                       $res[0]
+               );
+       }
+
+       public function testHeadHtml() {
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'page' => __CLASS__,
+                       'prop' => 'headhtml',
+               ] );
+
+               // Just do a rough sanity check
+               $this->assertRegExp( '#<!DOCTYPE.*<html.*<head.*</head>.*<body#s',
+                       $res[0]['parse']['headhtml'] );
+               $this->assertArrayNotHasKey( 'warnings', $res[0] );
+       }
+
+       public function testCategoriesHtml() {
+               $name = ucfirst( __FUNCTION__ );
+
+               $this->editPage( $name, "[[Category:$name]]" );
+
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'page' => $name,
+                       'prop' => 'categorieshtml',
+               ] );
+
+               $this->assertRegExp( "#Category.*Category:$name.*$name#",
+                       $res[0]['parse']['categorieshtml'] );
+               $this->assertArrayNotHasKey( 'warnings', $res[0] );
+       }
+
+       public function testEffectiveLangLinks() {
+               $hookRan = false;
+               $this->setTemporaryHook( 'LanguageLinks',
+                       function () use ( &$hookRan ) {
+                               $hookRan = true;
+                       }
+               );
+
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'title' => __CLASS__,
+                       'text' => '[[zh:' . __CLASS__ . ']]',
+                       'effectivelanglinks' => '',
+               ] );
+
+               $this->assertTrue( $hookRan );
+               $this->assertSame( 'The parameter "effectivelanglinks" has been deprecated.',
+                       $res[0]['warnings']['parse']['warnings'] );
+       }
+
+       /**
+        * @param array $arr Extra params to add to API request
+        */
+       private function doTestLangLinks( array $arr = [] ) {
+               $this->setupInterwiki();
+
+               $res = $this->doApiRequest( array_merge( [
+                       'action' => 'parse',
+                       'title' => 'Omelette',
+                       'text' => '[[madeuplanguage:Omelette]]',
+                       'prop' => 'langlinks',
+               ], $arr ) );
+
+               $langLinks = $res[0]['parse']['langlinks'];
+
+               $this->assertCount( 1, $langLinks );
+               $this->assertSame( 'madeuplanguage', $langLinks[0]['lang'] );
+               $this->assertSame( 'Omelette', $langLinks[0]['title'] );
+               $this->assertSame( 'https://example.com/wiki/Omelette', $langLinks[0]['url'] );
+               $this->assertArrayNotHasKey( 'warnings', $res[0] );
+       }
+
+       public function testLangLinks() {
+               $this->doTestLangLinks();
+       }
+
+       public function testLangLinksWithSkin() {
+               $this->setupSkin();
+               $this->doTestLangLinks( [ 'useskin' => 'testing' ] );
+       }
+
+       public function testHeadItems() {
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'title' => __CLASS__,
+                       'text' => '',
+                       'prop' => 'headitems',
+               ] );
+
+               $this->assertSame( [], $res[0]['parse']['headitems'] );
+               $this->assertSame(
+                       '"prop=headitems" is deprecated since MediaWiki 1.28. ' .
+                               'Use "prop=headhtml" when creating new HTML documents, ' .
+                               'or "prop=modules|jsconfigvars" when updating a document client-side.',
+                       $res[0]['warnings']['parse']['warnings']
+               );
+       }
+
+       public function testHeadItemsWithSkin() {
+               $this->setupSkin();
+
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'title' => __CLASS__,
+                       'text' => '',
+                       'prop' => 'headitems',
+                       'useskin' => 'testing',
+               ] );
+
+               $this->assertSame( [], $res[0]['parse']['headitems'] );
+               $this->assertSame(
+                       '"prop=headitems" is deprecated since MediaWiki 1.28. ' .
+                               'Use "prop=headhtml" when creating new HTML documents, ' .
+                               'or "prop=modules|jsconfigvars" when updating a document client-side.',
+                       $res[0]['warnings']['parse']['warnings']
+               );
+       }
+
+       public function testModules() {
+               $this->setTemporaryHook( 'ParserAfterParse',
+                       function ( $parser ) {
+                               $output = $parser->getOutput();
+                               $output->addModules( [ 'foo', 'bar' ] );
+                               $output->addModuleScripts( [ 'baz', 'quuz' ] );
+                               $output->addModuleStyles( [ 'aaa', 'zzz' ] );
+                               $output->addJsConfigVars( [ 'x' => 'y', 'z' => -3 ] );
+                       }
+               );
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'title' => __CLASS__,
+                       'text' => 'Content',
+                       'prop' => 'modules|jsconfigvars|encodedjsconfigvars',
+               ] );
+
+               $this->assertSame( [ 'foo', 'bar' ], $res[0]['parse']['modules'] );
+               $this->assertSame( [ 'baz', 'quuz' ], $res[0]['parse']['modulescripts'] );
+               $this->assertSame( [ 'aaa', 'zzz' ], $res[0]['parse']['modulestyles'] );
+               $this->assertSame( [ 'x' => 'y', 'z' => -3 ], $res[0]['parse']['jsconfigvars'] );
+               $this->assertSame( '{"x":"y","z":-3}', $res[0]['parse']['encodedjsconfigvars'] );
+               $this->assertArrayNotHasKey( 'warnings', $res[0] );
+       }
+
+       public function testModulesWithSkin() {
+               $this->setupSkin();
 
                $res = $this->doApiRequest( [
                        'action' => 'parse',
@@ -170,5 +690,160 @@ class ApiParseTest extends ApiTestCase {
                        $res[0]['parse']['modulestyles'],
                        'resp.parse.modulestyles'
                );
+               $this->assertSame(
+                       [ 'parse' =>
+                               [ 'warnings' =>
+                                       'Property "modules" was set but not "jsconfigvars" or ' .
+                                       '"encodedjsconfigvars". Configuration variables are necessary for ' .
+                                       'proper module usage.'
+                               ]
+                       ],
+                       $res[0]['warnings']
+               );
+       }
+
+       public function testIndicators() {
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'title' => __CLASS__,
+                       'text' =>
+                               '<indicator name="b">BBB!</indicator>Some text<indicator name="a">aaa</indicator>',
+                       'prop' => 'indicators',
+               ] );
+
+               $this->assertSame(
+                       // It seems we return in markup order and not display order
+                       [ 'b' => 'BBB!', 'a' => 'aaa' ],
+                       $res[0]['parse']['indicators']
+               );
+               $this->assertArrayNotHasKey( 'warnings', $res[0] );
+       }
+
+       public function testIndicatorsWithSkin() {
+               $this->setupSkin();
+
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'title' => __CLASS__,
+                       'text' =>
+                               '<indicator name="b">BBB!</indicator>Some text<indicator name="a">aaa</indicator>',
+                       'prop' => 'indicators',
+                       'useskin' => 'testing',
+               ] );
+
+               $this->assertSame(
+                       // Now we return in display order rather than markup order
+                       [ 'a' => 'aaa', 'b' => 'BBB!' ],
+                       $res[0]['parse']['indicators']
+               );
+               $this->assertArrayNotHasKey( 'warnings', $res[0] );
+       }
+
+       public function testIwlinks() {
+               $this->setupInterwiki();
+
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'title' => 'Omelette',
+                       'text' => '[[:madeuplanguage:Omelette]][[madeuplanguage:Spaghetti]]',
+                       'prop' => 'iwlinks',
+               ] );
+
+               $iwlinks = $res[0]['parse']['iwlinks'];
+
+               $this->assertCount( 1, $iwlinks );
+               $this->assertSame( 'madeuplanguage', $iwlinks[0]['prefix'] );
+               $this->assertSame( 'https://example.com/wiki/Omelette', $iwlinks[0]['url'] );
+               $this->assertSame( 'madeuplanguage:Omelette', $iwlinks[0]['title'] );
+               $this->assertArrayNotHasKey( 'warnings', $res[0] );
+       }
+
+       public function testLimitReports() {
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'pageid' => self::$pageId,
+                       'prop' => 'limitreportdata|limitreporthtml',
+               ] );
+
+               // We don't bother testing the actual values here
+               $this->assertInternalType( 'array', $res[0]['parse']['limitreportdata'] );
+               $this->assertInternalType( 'string', $res[0]['parse']['limitreporthtml'] );
+               $this->assertArrayNotHasKey( 'warnings', $res[0] );
+       }
+
+       public function testParseTreeNonWikitext() {
+               $this->setExpectedException( ApiUsageException::class,
+                       '"prop=parsetree" is only supported for wikitext content.' );
+
+               $this->doApiRequest( [
+                       'action' => 'parse',
+                       'text' => '',
+                       'contentmodel' => 'json',
+                       'prop' => 'parsetree',
+               ] );
+       }
+
+       public function testParseTree() {
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'text' => "Some ''text'' is {{nice|to have|i=think}}",
+                       'contentmodel' => 'wikitext',
+                       'prop' => 'parsetree',
+               ] );
+
+               // Preprocessor_DOM and Preprocessor_Hash give different results here,
+               // so we'll accept either
+               $this->assertRegExp(
+                       '#^<root>Some \'\'text\'\' is <template><title>nice</title>' .
+                               '<part><name index="1"/><value>to have</value></part>' .
+                               '<part><name>i</name>(?:<equals>)?=(?:</equals>)?<value>think</value></part>' .
+                               '</template></root>$#',
+                       $res[0]['parse']['parsetree']
+               );
+               $this->assertArrayNotHasKey( 'warnings', $res[0] );
+       }
+
+       public function testDisableTidy() {
+               $this->setMwGlobals( 'wgTidyConfig', [ 'driver' => 'RemexHtml' ] );
+
+               // Check that disabletidy doesn't have an effect just because tidying
+               // doesn't work for some other reason
+               $res1 = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'text' => "<b>Mixed <i>up</b></i>",
+                       'contentmodel' => 'wikitext',
+               ] );
+               $this->assertParsedTo( "<p><b>Mixed <i>up</i></b>\n</p>", $res1 );
+
+               $res2 = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'text' => "<b>Mixed <i>up</b></i>",
+                       'contentmodel' => 'wikitext',
+                       'disabletidy' => '',
+               ] );
+
+               $this->assertParsedTo( "<p><b>Mixed <i>up</b></i>\n</p>", $res2 );
+       }
+
+       public function testFormatCategories() {
+               $name = ucfirst( __FUNCTION__ );
+
+               $this->editPage( "Category:$name", 'Content' );
+               $this->editPage( 'Category:Hidden', '__HIDDENCAT__' );
+
+               $res = $this->doApiRequest( [
+                       'action' => 'parse',
+                       'title' => __CLASS__,
+                       'text' => "[[Category:$name]][[Category:Foo|Sort me]][[Category:Hidden]]",
+                       'prop' => 'categories',
+               ] );
+
+               $this->assertSame(
+                       [ [ 'sortkey' => '', 'category' => $name ],
+                               [ 'sortkey' => 'Sort me', 'category' => 'Foo', 'missing' => true ],
+                               [ 'sortkey' => '', 'category' => 'Hidden', 'hidden' => true ] ],
+                       $res[0]['parse']['categories']
+               );
+               $this->assertArrayNotHasKey( 'warnings', $res[0] );
        }
 }
index 31c8136..a5ee7dd 100644 (file)
@@ -56,6 +56,28 @@ abstract class ApiTestCase extends MediaWikiLangTestCase {
                return $page->doEditContent( ContentHandler::makeContent( $text, $title ), $summary );
        }
 
+       /**
+        * Revision-deletes a revision.
+        *
+        * @param Revision|int $rev Revision to delete
+        * @param array $value Keys are Revision::DELETED_* flags.  Values are 1 to set the bit, 0 to
+        *   clear, -1 to leave alone.  (All other values also clear the bit.)
+        * @param string $comment Deletion comment
+        */
+       protected function revisionDelete(
+               $rev, array $value = [ Revision::DELETED_TEXT => 1 ], $comment = ''
+       ) {
+               if ( is_int( $rev ) ) {
+                       $rev = Revision::newFromId( $rev );
+               }
+               RevisionDeleter::createList(
+                       'revision', RequestContext::getMain(), $rev->getTitle(), [ $rev->getId() ]
+               )->setVisibility( [
+                       'value' => $value,
+                       'comment' => $comment,
+               ] );
+       }
+
        /**
         * Does the API request and returns the result.
         *
@@ -99,6 +121,10 @@ abstract class ApiTestCase extends MediaWikiLangTestCase {
                }
 
                if ( $tokenType !== null ) {
+                       if ( $tokenType === 'auto' ) {
+                               $tokenType = ( new ApiMain() )->getModuleManager()
+                                       ->getModule( $params['action'], 'action' )->needsToken();
+                       }
                        $params['token'] = ApiQueryTokens::getToken(
                                $wgUser, $sessionObj, ApiQueryTokens::getTokenTypeSalts()[$tokenType]
                        )->toString();
@@ -142,7 +168,7 @@ abstract class ApiTestCase extends MediaWikiLangTestCase {
         * @return array Result of the API call
         */
        protected function doApiRequestWithToken( array $params, array $session = null,
-               User $user = null, $tokenType = 'csrf'
+               User $user = null, $tokenType = 'auto'
        ) {
                return $this->doApiRequest( $params, $session, false, $user, $tokenType );
        }
diff --git a/tests/phpunit/includes/api/ApiUserrightsTest.php b/tests/phpunit/includes/api/ApiUserrightsTest.php
new file mode 100644 (file)
index 0000000..0229e76
--- /dev/null
@@ -0,0 +1,358 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiUserrights
+ */
+class ApiUserrightsTest extends ApiTestCase {
+       /**
+        * Unsets $wgGroupPermissions['bureaucrat']['userrights'], and sets
+        * $wgAddGroups['bureaucrat'] and $wgRemoveGroups['bureaucrat'] to the
+        * specified values.
+        *
+        * @param array|bool $add Groups bureaucrats should be allowed to add, true for all
+        * @param array|bool $remove Groups bureaucrats should be allowed to remove, true for all
+        */
+       protected function setPermissions( $add = [], $remove = [] ) {
+               global $wgAddGroups, $wgRemoveGroups;
+
+               $this->setGroupPermissions( 'bureaucrat', 'userrights', false );
+
+               if ( $add ) {
+                       $this->stashMwGlobals( 'wgAddGroups' );
+                       $wgAddGroups['bureaucrat'] = $add;
+               }
+               if ( $remove ) {
+                       $this->stashMwGlobals( 'wgRemoveGroups' );
+                       $wgRemoveGroups['bureaucrat'] = $remove;
+               }
+       }
+
+       /**
+        * Perform an API userrights request that's expected to be successful.
+        *
+        * @param array|string $expectedGroups Group(s) that the user is expected
+        *   to have after the API request
+        * @param array $params Array to pass to doApiRequestWithToken().  'action'
+        *   => 'userrights' is implicit.  If no 'user' or 'userid' is specified,
+        *   we add a 'user' parameter.  If no 'add' or 'remove' is specified, we
+        *   add 'add' => 'sysop'.
+        * @param User|null $user The user that we're modifying.  The user must be
+        *   mutable, because we're going to change its groups!  null means that
+        *   we'll make up our own user to modify, and doesn't make sense if 'user'
+        *   or 'userid' is specified in $params.
+        */
+       protected function doSuccessfulRightsChange(
+               $expectedGroups = 'sysop', array $params = [], User $user = null
+       ) {
+               $expectedGroups = (array)$expectedGroups;
+               $params['action'] = 'userrights';
+
+               if ( !$user ) {
+                       $user = $this->getMutableTestUser()->getUser();
+               }
+
+               $this->assertTrue( TestUserRegistry::isMutable( $user ),
+                       'Immutable user passed to doSuccessfulRightsChange!' );
+
+               if ( !isset( $params['user'] ) && !isset( $params['userid'] ) ) {
+                       $params['user'] = $user->getName();
+               }
+               if ( !isset( $params['add'] ) && !isset( $params['remove'] ) ) {
+                       $params['add'] = 'sysop';
+               }
+
+               $res = $this->doApiRequestWithToken( $params );
+
+               $user->clearInstanceCache();
+               $this->assertSame( $expectedGroups, $user->getGroups() );
+
+               $this->assertArrayNotHasKey( 'warnings', $res[0] );
+       }
+
+       /**
+        * Perform an API userrights request that's expected to fail.
+        *
+        * @param string $expectedException Expected exception text
+        * @param array $params As for doSuccessfulRightsChange()
+        * @param User|null $user As for doSuccessfulRightsChange().  If there's no
+        *   user who will possibly be affected (such as if an invalid username is
+        *   provided in $params), pass null.
+        */
+       protected function doFailedRightsChange(
+               $expectedException, array $params = [], User $user = null
+       ) {
+               $params['action'] = 'userrights';
+
+               $this->setExpectedException( ApiUsageException::class, $expectedException );
+
+               if ( !$user ) {
+                       // If 'user' or 'userid' is specified and $user was not specified,
+                       // the user we're creating now will have nothing to do with the API
+                       // request, but that's okay, since we're just testing that it has
+                       // no groups.
+                       $user = $this->getMutableTestUser()->getUser();
+               }
+
+               $this->assertTrue( TestUserRegistry::isMutable( $user ),
+                       'Immutable user passed to doFailedRightsChange!' );
+
+               if ( !isset( $params['user'] ) && !isset( $params['userid'] ) ) {
+                       $params['user'] = $user->getName();
+               }
+               if ( !isset( $params['add'] ) && !isset( $params['remove'] ) ) {
+                       $params['add'] = 'sysop';
+               }
+               $expectedGroups = $user->getGroups();
+
+               try {
+                       $this->doApiRequestWithToken( $params );
+               } finally {
+                       $user->clearInstanceCache();
+                       $this->assertSame( $expectedGroups, $user->getGroups() );
+               }
+       }
+
+       public function testAdd() {
+               $this->doSuccessfulRightsChange();
+       }
+
+       public function testBlockedWithUserrights() {
+               global $wgUser;
+
+               $block = new Block( [ 'address' => $wgUser, 'by' => $wgUser->getId(), ] );
+               $block->insert();
+
+               try {
+                       $this->doSuccessfulRightsChange();
+               } finally {
+                       $block->delete();
+                       $wgUser->clearInstanceCache();
+               }
+       }
+
+       public function testBlockedWithoutUserrights() {
+               $user = $this->getTestSysop()->getUser();
+
+               $this->setPermissions( true, true );
+
+               $block = new Block( [ 'address' => $user, 'by' => $user->getId() ] );
+               $block->insert();
+
+               try {
+                       $this->doFailedRightsChange( 'You have been blocked from editing.' );
+               } finally {
+                       $block->delete();
+                       $user->clearInstanceCache();
+               }
+       }
+
+       public function testAddMultiple() {
+               $this->doSuccessfulRightsChange(
+                       [ 'bureaucrat', 'sysop' ],
+                       [ 'add' => 'bureaucrat|sysop' ]
+               );
+       }
+
+       public function testTooFewExpiries() {
+               $this->doFailedRightsChange(
+                       '2 expiry timestamps were provided where 3 were needed.',
+                       [ 'add' => 'sysop|bureaucrat|bot', 'expiry' => 'infinity|tomorrow' ]
+               );
+       }
+
+       public function testTooManyExpiries() {
+               $this->doFailedRightsChange(
+                       '3 expiry timestamps were provided where 2 were needed.',
+                       [ 'add' => 'sysop|bureaucrat', 'expiry' => 'infinity|tomorrow|never' ]
+               );
+       }
+
+       public function testInvalidExpiry() {
+               $this->doFailedRightsChange( 'Invalid expiry time', [ 'expiry' => 'yummy lollipops!' ] );
+       }
+
+       public function testMultipleInvalidExpiries() {
+               $this->doFailedRightsChange(
+                       'Invalid expiry time "foo".',
+                       [ 'add' => 'sysop|bureaucrat', 'expiry' => 'foo|bar' ]
+               );
+       }
+
+       public function testWithTag() {
+               ChangeTags::defineTag( 'custom tag' );
+
+               $user = $this->getMutableTestUser()->getUser();
+
+               $this->doSuccessfulRightsChange( 'sysop', [ 'tags' => 'custom tag' ], $user );
+
+               $dbr = wfGetDB( DB_REPLICA );
+               $this->assertSame(
+                       'custom tag',
+                       $dbr->selectField(
+                               [ 'change_tag', 'logging' ],
+                               'ct_tag',
+                               [
+                                       'ct_log_id = log_id',
+                                       'log_namespace' => NS_USER,
+                                       'log_title' => strtr( $user->getName(), ' ', '_' )
+                               ],
+                               __METHOD__
+                       )
+               );
+       }
+
+       public function testWithoutTagPermission() {
+               global $wgGroupPermissions;
+
+               ChangeTags::defineTag( 'custom tag' );
+
+               $this->stashMwGlobals( 'wgGroupPermissions' );
+               $wgGroupPermissions['user']['applychangetags'] = false;
+
+               $this->doFailedRightsChange(
+                       'You do not have permission to apply change tags along with your changes.',
+                       [ 'tags' => 'custom tag' ]
+               );
+       }
+
+       public function testNonexistentUser() {
+               $this->doFailedRightsChange(
+                       'There is no user by the name "Nonexistent user". Check your spelling.',
+                       [ 'user' => 'Nonexistent user' ]
+               );
+       }
+
+       public function testWebToken() {
+               $sysop = $this->getTestSysop()->getUser();
+               $user = $this->getMutableTestUser()->getUser();
+
+               $token = $sysop->getEditToken( $user->getName() );
+
+               $res = $this->doApiRequest( [
+                       'action' => 'userrights',
+                       'user' => $user->getName(),
+                       'add' => 'sysop',
+                       'token' => $token,
+               ] );
+
+               $user->clearInstanceCache();
+               $this->assertSame( [ 'sysop' ], $user->getGroups() );
+
+               $this->assertArrayNotHasKey( 'warnings', $res[0] );
+       }
+
+       /**
+        * Helper for testCanProcessExpiries that returns a mock ApiUserrights that either can or cannot
+        * process expiries.  Although the regular page can process expiries, we use a mock here to
+        * ensure that it's the result of canProcessExpiries() that makes a difference, and not some
+        * error in the way we construct the mock.
+        *
+        * @param bool $canProcessExpiries
+        */
+       private function getMockForProcessingExpiries( $canProcessExpiries ) {
+               $sysop = $this->getTestSysop()->getUser();
+               $user = $this->getMutableTestUser()->getUser();
+
+               $token = $sysop->getEditToken( 'userrights' );
+
+               $main = new ApiMain( new FauxRequest( [
+                       'action' => 'userrights',
+                       'user' => $user->getName(),
+                       'add' => 'sysop',
+                       'token' => $token,
+               ] ) );
+
+               $mockUserRightsPage = $this->getMockBuilder( UserrightsPage::class )
+                       ->setMethods( [ 'canProcessExpiries' ] )
+                       ->getMock();
+               $mockUserRightsPage->method( 'canProcessExpiries' )->willReturn( $canProcessExpiries );
+
+               $mockApi = $this->getMockBuilder( ApiUserrights::class )
+                       ->setConstructorArgs( [ $main, 'userrights' ] )
+                       ->setMethods( [ 'getUserRightsPage' ] )
+                       ->getMock();
+               $mockApi->method( 'getUserRightsPage' )->willReturn( $mockUserRightsPage );
+
+               return $mockApi;
+       }
+
+       public function testCanProcessExpiries() {
+               $mock1 = $this->getMockForProcessingExpiries( true );
+               $this->assertArrayHasKey( 'expiry', $mock1->getAllowedParams() );
+
+               $mock2 = $this->getMockForProcessingExpiries( false );
+               $this->assertArrayNotHasKey( 'expiry', $mock2->getAllowedParams() );
+       }
+
+       /**
+        * Tests adding and removing various groups with various permissions.
+        *
+        * @dataProvider addAndRemoveGroupsProvider
+        * @param array|null $permissions [ [ $wgAddGroups, $wgRemoveGroups ] ] or null for 'userrights'
+        *   to be set in $wgGroupPermissions
+        * @param array $groupsToChange [ [ groups to add ], [ groups to remove ] ]
+        * @param array $expectedGroups Array of expected groups
+        */
+       public function testAddAndRemoveGroups(
+               array $permissions = null, array $groupsToChange, array $expectedGroups
+       ) {
+               if ( $permissions !== null ) {
+                       $this->setPermissions( $permissions[0], $permissions[1] );
+               }
+
+               $params = [
+                       'add' => implode( '|', $groupsToChange[0] ),
+                       'remove' => implode( '|', $groupsToChange[1] ),
+               ];
+
+               // We'll take a bot so we have a group to remove
+               $user = $this->getMutableTestUser( [ 'bot' ] )->getUser();
+
+               $this->doSuccessfulRightsChange( $expectedGroups, $params, $user );
+       }
+
+       public function addAndRemoveGroupsProvider() {
+               return [
+                       'Simple add' => [
+                               [ [ 'sysop' ], [] ],
+                               [ [ 'sysop' ], [] ],
+                               [ 'bot', 'sysop' ]
+                       ], 'Add with only remove permission' => [
+                               [ [], [ 'sysop' ] ],
+                               [ [ 'sysop' ], [] ],
+                               [ 'bot' ],
+                       ], 'Add with global remove permission' => [
+                               [ [], true ],
+                               [ [ 'sysop' ], [] ],
+                               [ 'bot' ],
+                       ], 'Simple remove' => [
+                               [ [], [ 'bot' ] ],
+                               [ [], [ 'bot' ] ],
+                               [],
+                       ], 'Remove with only add permission' => [
+                               [ [ 'bot' ], [] ],
+                               [ [], [ 'bot' ] ],
+                               [ 'bot' ],
+                       ], 'Remove with global add permission' => [
+                               [ true, [] ],
+                               [ [], [ 'bot' ] ],
+                               [ 'bot' ],
+                       ], 'Add and remove same new group' => [
+                               null,
+                               [ [ 'sysop' ], [ 'sysop' ] ],
+                               // The userrights code does removals before adds, so it doesn't remove the sysop
+                               // group here and only adds it.
+                               [ 'bot', 'sysop' ],
+                       ], 'Add and remove same existing group' => [
+                               null,
+                               [ [ 'bot' ], [ 'bot' ] ],
+                               // But here it first removes the existing group and then re-adds it.
+                               [ 'bot' ],
+                       ],
+               ];
+       }
+}
index 211eba0..cc16248 100644 (file)
@@ -879,14 +879,10 @@ class AuthManagerTest extends \MediaWikiTestCase {
                        );
                        $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' )
                                ->will( $this->returnValue( $key ) );
-                       $mocks[$key . '2'] = $this->getMockForAbstractClass(
-                               "MediaWiki\\Auth\\$class", [], "Mock$class"
-                       );
+                       $mocks[$key . '2'] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" );
                        $mocks[$key . '2']->expects( $this->any() )->method( 'getUniqueId' )
                                ->will( $this->returnValue( $key . '2' ) );
-                       $mocks[$key . '3'] = $this->getMockForAbstractClass(
-                               "MediaWiki\\Auth\\$class", [], "Mock$class"
-                       );
+                       $mocks[$key . '3'] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" );
                        $mocks[$key . '3']->expects( $this->any() )->method( 'getUniqueId' )
                                ->will( $this->returnValue( $key . '3' ) );
                }
@@ -1901,9 +1897,7 @@ class AuthManagerTest extends \MediaWikiTestCase {
                                ) );
 
                        for ( $i = 2; $i <= 3; $i++ ) {
-                               $mocks[$key . $i] = $this->getMockForAbstractClass(
-                                       "MediaWiki\\Auth\\$class", [], "Mock$class"
-                               );
+                               $mocks[$key . $i] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" );
                                $mocks[$key . $i]->expects( $this->any() )->method( 'getUniqueId' )
                                        ->will( $this->returnValue( $key . $i ) );
                                $mocks[$key . $i]->expects( $this->any() )->method( 'testUserForCreation' )
@@ -2368,9 +2362,7 @@ class AuthManagerTest extends \MediaWikiTestCase {
                $mocks = [];
                foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) {
                        $class = ucfirst( $key ) . 'AuthenticationProvider';
-                       $mocks[$key] = $this->getMockForAbstractClass(
-                               "MediaWiki\\Auth\\$class", [], "Mock$class"
-                       );
+                       $mocks[$key] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" );
                        $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' )
                                ->will( $this->returnValue( $key ) );
                }
@@ -2848,9 +2840,11 @@ class AuthManagerTest extends \MediaWikiTestCase {
                $mocks = [];
                foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) {
                        $class = ucfirst( $key ) . 'AuthenticationProvider';
-                       $mocks[$key] = $this->getMockForAbstractClass(
-                               "MediaWiki\\Auth\\$class", [], "Mock$class"
-                       );
+                       $mocks[$key] = $this->getMockBuilder( "MediaWiki\\Auth\\$class" )
+                               ->setMethods( [
+                                       'getUniqueId', 'getAuthenticationRequests', 'providerAllowsAuthenticationDataChange',
+                               ] )
+                               ->getMockForAbstractClass();
                        $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' )
                                ->will( $this->returnValue( $key ) );
                        $mocks[$key]->expects( $this->any() )->method( 'getAuthenticationRequests' )
@@ -2868,9 +2862,12 @@ class AuthManagerTest extends \MediaWikiTestCase {
                        PrimaryAuthenticationProvider::TYPE_LINK
                ] as $type ) {
                        $class = 'PrimaryAuthenticationProvider';
-                       $mocks["primary-$type"] = $this->getMockForAbstractClass(
-                               "MediaWiki\\Auth\\$class", [], "Mock$class"
-                       );
+                       $mocks["primary-$type"] = $this->getMockBuilder( "MediaWiki\\Auth\\$class" )
+                               ->setMethods( [
+                                       'getUniqueId', 'accountCreationType', 'getAuthenticationRequests',
+                                       'providerAllowsAuthenticationDataChange',
+                               ] )
+                               ->getMockForAbstractClass();
                        $mocks["primary-$type"]->expects( $this->any() )->method( 'getUniqueId' )
                                ->will( $this->returnValue( "primary-$type" ) );
                        $mocks["primary-$type"]->expects( $this->any() )->method( 'accountCreationType' )
@@ -2885,9 +2882,12 @@ class AuthManagerTest extends \MediaWikiTestCase {
                        $this->primaryauthMocks[] = $mocks["primary-$type"];
                }
 
-               $mocks['primary2'] = $this->getMockForAbstractClass(
-                       PrimaryAuthenticationProvider::class, [], "MockPrimaryAuthenticationProvider"
-               );
+               $mocks['primary2'] = $this->getMockBuilder( PrimaryAuthenticationProvider::class )
+                       ->setMethods( [
+                               'getUniqueId', 'accountCreationType', 'getAuthenticationRequests',
+                               'providerAllowsAuthenticationDataChange',
+                       ] )
+                       ->getMockForAbstractClass();
                $mocks['primary2']->expects( $this->any() )->method( 'getUniqueId' )
                        ->will( $this->returnValue( 'primary2' ) );
                $mocks['primary2']->expects( $this->any() )->method( 'accountCreationType' )
@@ -3138,9 +3138,7 @@ class AuthManagerTest extends \MediaWikiTestCase {
                $mocks = [];
                foreach ( [ 'primary', 'secondary' ] as $key ) {
                        $class = ucfirst( $key ) . 'AuthenticationProvider';
-                       $mocks[$key] = $this->getMockForAbstractClass(
-                               "MediaWiki\\Auth\\$class", [], "Mock$class"
-                       );
+                       $mocks[$key] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" );
                        $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' )
                                ->will( $this->returnValue( $key ) );
                        $mocks[$key]->expects( $this->any() )->method( 'providerAllowsPropertyChange' )
@@ -3224,8 +3222,7 @@ class AuthManagerTest extends \MediaWikiTestCase {
        public function testAutoCreateFailOnLogin() {
                $username = self::usernameForCreation();
 
-               $mock = $this->getMockForAbstractClass(
-                       PrimaryAuthenticationProvider::class, [], "MockPrimaryAuthenticationProvider" );
+               $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class );
                $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'primary' ) );
                $mock->expects( $this->any() )->method( 'beginPrimaryAuthentication' )
                        ->will( $this->returnValue( AuthenticationResponse::newPass( $username ) ) );
index 6ced540..cd48d90 100644 (file)
@@ -67,18 +67,20 @@ class LoadBalancerTest extends MediaWikiTestCase {
                $this->assertTrue( $dbr->getLBInfo( 'master' ), 'DB_REPLICA also gets the master' );
                $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX set on replica" );
 
-               $dbwAuto = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTO );
-               $this->assertFalse( $dbwAuto->getFlag( $dbw::DBO_TRX ), "No DBO_TRX with CONN_TRX_AUTO" );
+               $dbwAuto = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTOCOMMIT );
+               $this->assertFalse(
+                       $dbwAuto->getFlag( $dbw::DBO_TRX ), "No DBO_TRX with CONN_TRX_AUTOCOMMIT" );
                $this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX ), "DBO_TRX still set on master" );
-               $this->assertNotEquals( $dbw, $dbwAuto, "CONN_TRX_AUTO uses separate connection" );
+               $this->assertNotEquals( $dbw, $dbwAuto, "CONN_TRX_AUTOCOMMIT uses separate connection" );
 
-               $dbrAuto = $lb->getConnection( DB_REPLICA, [], false, $lb::CONN_TRX_AUTO );
-               $this->assertFalse( $dbrAuto->getFlag( $dbw::DBO_TRX ), "No DBO_TRX with CONN_TRX_AUTO" );
+               $dbrAuto = $lb->getConnection( DB_REPLICA, [], false, $lb::CONN_TRX_AUTOCOMMIT );
+               $this->assertFalse(
+                       $dbrAuto->getFlag( $dbw::DBO_TRX ), "No DBO_TRX with CONN_TRX_AUTOCOMMIT" );
                $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX still set on replica" );
-               $this->assertNotEquals( $dbr, $dbrAuto, "CONN_TRX_AUTO uses separate connection" );
+               $this->assertNotEquals( $dbr, $dbrAuto, "CONN_TRX_AUTOCOMMIT uses separate connection" );
 
-               $dbwAuto2 = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTO );
-               $this->assertEquals( $dbwAuto2, $dbwAuto, "CONN_TRX_AUTO reuses connections" );
+               $dbwAuto2 = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTOCOMMIT );
+               $this->assertEquals( $dbwAuto2, $dbwAuto, "CONN_TRX_AUTOCOMMIT reuses connections" );
 
                $lb->closeAll();
        }
@@ -135,18 +137,20 @@ class LoadBalancerTest extends MediaWikiTestCase {
                $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX set on replica" );
                $this->assertWriteForbidden( $dbr );
 
-               $dbwAuto = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTO );
-               $this->assertFalse( $dbwAuto->getFlag( $dbw::DBO_TRX ), "No DBO_TRX with CONN_TRX_AUTO" );
+               $dbwAuto = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTOCOMMIT );
+               $this->assertFalse(
+                       $dbwAuto->getFlag( $dbw::DBO_TRX ), "No DBO_TRX with CONN_TRX_AUTOCOMMIT" );
                $this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX ), "DBO_TRX still set on master" );
-               $this->assertNotEquals( $dbw, $dbwAuto, "CONN_TRX_AUTO uses separate connection" );
+               $this->assertNotEquals( $dbw, $dbwAuto, "CONN_TRX_AUTOCOMMIT uses separate connection" );
 
-               $dbrAuto = $lb->getConnection( DB_REPLICA, [], false, $lb::CONN_TRX_AUTO );
-               $this->assertFalse( $dbrAuto->getFlag( $dbw::DBO_TRX ), "No DBO_TRX with CONN_TRX_AUTO" );
+               $dbrAuto = $lb->getConnection( DB_REPLICA, [], false, $lb::CONN_TRX_AUTOCOMMIT );
+               $this->assertFalse(
+                       $dbrAuto->getFlag( $dbw::DBO_TRX ), "No DBO_TRX with CONN_TRX_AUTOCOMMIT" );
                $this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX still set on replica" );
-               $this->assertNotEquals( $dbr, $dbrAuto, "CONN_TRX_AUTO uses separate connection" );
+               $this->assertNotEquals( $dbr, $dbrAuto, "CONN_TRX_AUTOCOMMIT uses separate connection" );
 
-               $dbwAuto2 = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTO );
-               $this->assertEquals( $dbwAuto2, $dbwAuto, "CONN_TRX_AUTO reuses connections" );
+               $dbwAuto2 = $lb->getConnection( DB_MASTER, [], false, $lb::CONN_TRX_AUTOCOMMIT );
+               $this->assertEquals( $dbwAuto2, $dbwAuto, "CONN_TRX_AUTOCOMMIT reuses connections" );
 
                $lb->closeAll();
        }
index 14e2e27..4c0ca04 100644 (file)
@@ -158,7 +158,8 @@ class KafkaHandlerTest extends MediaWikiTestCase {
                        ->method( 'send' )
                        ->will( $this->returnValue( true ) );
                // evil hax
-               TestingAccessWrapper::newFromObject( $mockMethod )->matcher->parametersMatcher =
+               $matcher = TestingAccessWrapper::newFromObject( $mockMethod )->matcher;
+               TestingAccessWrapper::newFromObject( $matcher )->parametersMatcher =
                        new \PHPUnit_Framework_MockObject_Matcher_ConsecutiveParameters( [
                                [ $this->anything(), $this->anything(), [ 'words' ] ],
                                [ $this->anything(), $this->anything(), [ 'lines' ] ]
index 0625edd..64dde77 100644 (file)
@@ -387,7 +387,7 @@ class JobQueueTest extends MediaWikiTestCase {
 class JobQueueDBSingle extends JobQueueDB {
        protected function getDB( $index ) {
                $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
-               // Override to not use CONN_TRX_AUTO so that we see the same temporary `job` table
+               // Override to not use CONN_TRX_AUTOCOMMIT so that we see the same temporary `job` table
                return $lb->getConnection( $index, [], $this->wiki );
        }
 }
index bc9d9ea..c3cddc6 100644 (file)
@@ -69,7 +69,7 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase {
 
                $lb->expects( $this->once() )
                        ->method( 'getConnection' )
-                       ->with( DB_MASTER, [ 'test' ], 'dummy', ILoadBalancer::CONN_TRX_AUTO )
+                       ->with( DB_MASTER, [ 'test' ], 'dummy', ILoadBalancer::CONN_TRX_AUTOCOMMIT )
                        ->willReturnCallback(
                                function () {
                                        return $this->getDatabaseMock();
@@ -78,7 +78,7 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase {
 
                $ref = new DBConnRef(
                        $lb,
-                       [ DB_MASTER, [ 'test' ], 'dummy', ILoadBalancer::CONN_TRX_AUTO ]
+                       [ DB_MASTER, [ 'test' ], 'dummy', ILoadBalancer::CONN_TRX_AUTOCOMMIT ]
                );
 
                $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) );
index 929ff0f..5dc7a96 100644 (file)
@@ -6,6 +6,7 @@
 class VersionCheckerTest extends PHPUnit\Framework\TestCase {
 
        use MediaWikiCoversValidator;
+       use PHPUnit4And6Compat;
 
        /**
         * @dataProvider provideCheck
index 5118218..aeaa1ae 100644 (file)
@@ -347,7 +347,7 @@ class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase
                $user = $this->getTestSysop()->getUser();
                $this->assertConditions(
                        [ # expected
-                               'rc_patrolled = 0',
+                               'rc_patrolled' => 0,
                        ],
                        [
                                'hidepatrolled' => 1,
@@ -361,7 +361,7 @@ class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase
                $user = $this->getTestSysop()->getUser();
                $this->assertConditions(
                        [ # expected
-                               'rc_patrolled != 0',
+                               'rc_patrolled' => [ 1, 2 ],
                        ],
                        [
                                'hideunpatrolled' => 1,
@@ -371,6 +371,30 @@ class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase
                );
        }
 
+       public function testRcReviewStatusFilter() {
+               $user = $this->getTestSysop()->getUser();
+               $this->assertConditions(
+                       [ #expected
+                               'rc_patrolled' => 1,
+                       ],
+                       [
+                               'reviewStatus' => 'manual'
+                       ],
+                       "rc conditions: reviewStatus=manual",
+                       $user
+               );
+               $this->assertConditions(
+                       [ #expected
+                               'rc_patrolled' => [ 0, 2 ],
+                       ],
+                       [
+                               'reviewStatus' => 'unpatrolled;auto'
+                       ],
+                       "rc conditions: reviewStatus=unpatrolled;auto",
+                       $user
+               );
+       }
+
        public function testRcHideminorFilter() {
                $this->assertConditions(
                        [ # expected
index f06a353..52b1433 100644 (file)
@@ -12,7 +12,7 @@
 class BatchRowUpdateTest extends MediaWikiTestCase {
 
        public function testWriterBasicFunctionality() {
-               $db = $this->mockDb();
+               $db = $this->mockDb( [ 'update' ] );
                $writer = new BatchRowWriter( $db, 'echo_event' );
 
                $updates = [
@@ -36,17 +36,13 @@ class BatchRowUpdateTest extends MediaWikiTestCase {
        }
 
        public function testReaderBasicIterate() {
-               $db = $this->mockDb();
                $batchSize = 2;
-               $reader = new BatchRowIterator( $db, 'some_table', 'id_field', $batchSize );
-
                $response = $this->genSelectResult( $batchSize, /*numRows*/ 5, function () {
                        static $i = 0;
                        return [ 'id_field' => ++$i ];
                } );
-               $db->expects( $this->exactly( count( $response ) ) )
-                       ->method( 'select' )
-                       ->will( $this->consecutivelyReturnFromSelect( $response ) );
+               $db = $this->mockDbConsecutiveSelect( $response );
+               $reader = new BatchRowIterator( $db, 'some_table', 'id_field', $batchSize );
 
                $pos = 0;
                foreach ( $reader as $rows ) {
@@ -130,7 +126,7 @@ class BatchRowUpdateTest extends MediaWikiTestCase {
        public function testReaderSetFetchColumns(
                $message, array $columns, array $primaryKeys, array $fetchColumns
        ) {
-               $db = $this->mockDb();
+               $db = $this->mockDb( [ 'select' ] );
                $db->expects( $this->once() )
                        ->method( 'select' )
                        // only testing second parameter of Database::select
@@ -202,7 +198,7 @@ class BatchRowUpdateTest extends MediaWikiTestCase {
        }
 
        protected function mockDbConsecutiveSelect( array $retvals ) {
-               $db = $this->mockDb();
+               $db = $this->mockDb( [ 'select', 'addQuotes' ] );
                $db->expects( $this->any() )
                        ->method( 'select' )
                        ->will( $this->consecutivelyReturnFromSelect( $retvals ) );
@@ -238,11 +234,12 @@ class BatchRowUpdateTest extends MediaWikiTestCase {
                return $res;
        }
 
-       protected function mockDb() {
+       protected function mockDb( $methods = [] ) {
                // @TODO: mock from Database
                // FIXME: the constructor normally sets mAtomicLevels and mSrvCache
                $databaseMysql = $this->getMockBuilder( Wikimedia\Rdbms\DatabaseMysqli::class )
                        ->disableOriginalConstructor()
+                       ->setMethods( array_merge( [ 'isOpen', 'getApproximateLagStatus' ], $methods ) )
                        ->getMock();
                $databaseMysql->expects( $this->any() )
                        ->method( 'isOpen' )
index 9485170..26f6908 100644 (file)
@@ -47,6 +47,7 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase {
        private function getMockCache() {
                $mock = $this->getMockBuilder( HashBagOStuff::class )
                        ->disableOriginalConstructor()
+                       ->setMethods( [ 'get', 'set', 'delete', 'makeKey' ] )
                        ->getMock();
                $mock->expects( $this->any() )
                        ->method( 'makeKey' )
@@ -2074,12 +2075,11 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase {
                        ->method( 'selectRow' );
 
                $mockCache = $this->getMockCache();
-               $mockDb->expects( $this->never() )
-                       ->method( 'get' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'set' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'delete' );
+               $mockCache->expects( $this->never() )->method( 'get' );
+               $mockCache->expects( $this->never() )->method( 'set' );
+               $mockCache->expects( $this->once() )
+                       ->method( 'delete' )
+                       ->with( '0:SomeDbKey:1' );
 
                $store = $this->newWatchedItemStore(
                        $this->getMockLoadBalancer( $mockDb ),
@@ -2168,12 +2168,11 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase {
                        ->method( 'selectRow' );
 
                $mockCache = $this->getMockCache();
-               $mockDb->expects( $this->never() )
-                       ->method( 'get' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'set' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'delete' );
+               $mockCache->expects( $this->never() )->method( 'get' );
+               $mockCache->expects( $this->never() )->method( 'set' );
+               $mockCache->expects( $this->once() )
+                       ->method( 'delete' )
+                       ->with( '0:SomeTitle:1' );
 
                $store = $this->newWatchedItemStore(
                        $this->getMockLoadBalancer( $mockDb ),
@@ -2235,12 +2234,13 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase {
                        ) );
 
                $mockCache = $this->getMockCache();
-               $mockDb->expects( $this->never() )
-                       ->method( 'get' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'set' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'delete' );
+               $mockCache->expects( $this->never() )->method( 'get' );
+               $mockCache->expects( $this->once() )
+                       ->method( 'set' )
+                       ->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
+               $mockCache->expects( $this->once() )
+                       ->method( 'delete' )
+                       ->with( '0:SomeDbKey:1' );
 
                $store = $this->newWatchedItemStore(
                        $this->getMockLoadBalancer( $mockDb ),
@@ -2311,12 +2311,11 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase {
                        ->will( $this->returnValue( false ) );
 
                $mockCache = $this->getMockCache();
-               $mockDb->expects( $this->never() )
-                       ->method( 'get' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'set' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'delete' );
+               $mockCache->expects( $this->never() )->method( 'get' );
+               $mockCache->expects( $this->never() )->method( 'set' );
+               $mockCache->expects( $this->once() )
+                       ->method( 'delete' )
+                       ->with( '0:SomeDbKey:1' );
 
                $store = $this->newWatchedItemStore(
                        $this->getMockLoadBalancer( $mockDb ),
@@ -2378,12 +2377,13 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase {
                        ) );
 
                $mockCache = $this->getMockCache();
-               $mockDb->expects( $this->never() )
-                       ->method( 'get' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'set' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'delete' );
+               $mockCache->expects( $this->never() )->method( 'get' );
+               $mockCache->expects( $this->once() )
+                       ->method( 'set' )
+                       ->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
+               $mockCache->expects( $this->once() )
+                       ->method( 'delete' )
+                       ->with( '0:SomeDbKey:1' );
 
                $store = $this->newWatchedItemStore(
                        $this->getMockLoadBalancer( $mockDb ),
@@ -2456,12 +2456,13 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase {
                        ) );
 
                $mockCache = $this->getMockCache();
-               $mockDb->expects( $this->never() )
-                       ->method( 'get' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'set' );
-               $mockDb->expects( $this->never() )
-                       ->method( 'delete' );
+               $mockCache->expects( $this->never() )->method( 'get' );
+               $mockCache->expects( $this->once() )
+                       ->method( 'set' )
+                       ->with( '0:SomeDbKey:1', $this->isType( 'object' ) );
+               $mockCache->expects( $this->once() )
+                       ->method( 'delete' )
+                       ->with( '0:SomeDbKey:1' );
 
                $store = $this->newWatchedItemStore(
                        $this->getMockLoadBalancer( $mockDb ),