Merge "Allow requiring cache size for page props"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Fri, 19 Aug 2016 19:39:17 +0000 (19:39 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Fri, 19 Aug 2016 19:39:17 +0000 (19:39 +0000)
57 files changed:
RELEASE-NOTES-1.28
autoload.php
includes/OutputPage.php
includes/api/ApiAuthManagerHelper.php
includes/api/ApiBase.php
includes/api/ApiLogin.php
includes/api/ApiMain.php
includes/api/ApiQueryBacklinksprop.php
includes/api/ApiUpload.php
includes/api/i18n/fr.json
includes/api/i18n/zh-hans.json
includes/auth/AuthManager.php
includes/auth/AuthenticationRequest.php
includes/auth/EmailNotificationSecondaryAuthenticationProvider.php
includes/auth/PasswordAuthenticationRequest.php
includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php
includes/content/ContentHandler.php
includes/content/WikiTextStructure.php
includes/content/WikitextContentHandler.php
includes/db/DBConnRef.php
includes/db/Database.php
includes/db/DatabasePostgres.php
includes/db/IDatabase.php
includes/htmlform/fields/HTMLFormFieldCloner.php
includes/jobqueue/jobs/AssembleUploadChunksJob.php
includes/libs/objectcache/EmptyBagOStuff.php
includes/libs/objectcache/WANObjectCache.php
includes/resourceloader/ResourceLoaderImageModule.php
includes/search/DummySearchIndexFieldDefinition.php [new file with mode: 0644]
includes/search/ParserOutputSearchDataExtractor.php [new file with mode: 0644]
includes/search/SearchIndexFieldDefinition.php
includes/specials/SpecialSearch.php
includes/upload/UploadBase.php
includes/upload/UploadFromChunks.php
includes/upload/UploadFromStash.php
languages/Language.php
languages/i18n/bn.json
languages/i18n/fr.json
languages/i18n/got.json
languages/i18n/nap.json
resources/ResourcesOOUI.php
resources/assets/licenses/README
resources/src/oojs-ui-styles-skip.js [deleted file]
tests/phpunit/includes/auth/AuthenticationRequestTest.php
tests/phpunit/includes/auth/AuthenticationRequestTestCase.php
tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php
tests/phpunit/includes/content/ContentHandlerTest.php
tests/phpunit/includes/content/WikitextStructureTest.php
tests/phpunit/includes/db/DatabaseTest.php
tests/phpunit/includes/libs/ObjectFactoryTest.php
tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php
tests/phpunit/includes/search/ParserOutputSearchDataExtractorTest.php [new file with mode: 0644]
tests/phpunit/specials/SpecialSearchTest.php [new file with mode: 0644]
tests/phpunit/structure/ResourcesTest.php
tests/qunit/QUnitTestResources.php
tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js [new file with mode: 0644]
tests/qunit/suites/resources/mediawiki/mediawiki.test.js

index f6c3530..eac4e2c 100644 (file)
@@ -61,6 +61,13 @@ production.
 * The following response properties from action=login, deprecated in 1.27, are
   now removed: lgtoken, cookieprefix, sessionid. Clients should handle cookies
   to properly manage session state.
+* Submitting the lgtoken and lgpassword parameters in the query string to
+  action=login is now deprecated and outputs a warning. They should be submitted
+  in the POST body instead.
+* Submitting sensitive authentication request parameters to action=clientlogin,
+  action=createaccount, action=linkaccount, and action=changeauthenticationdata
+  in the query string is now deprecated and outputs a warning. They should be
+  submitted in the POST body instead.
 
 === Action API internal changes in 1.28 ===
 * Added a new hook, 'ApiMakeParserOptions', to allow extensions to better
index fc6577e..e37a011 100644 (file)
@@ -372,6 +372,7 @@ $wgAutoloadLocalClasses = [
        'DoubleRedirectsPage' => __DIR__ . '/includes/specials/SpecialDoubleRedirects.php',
        'DoubleReplacer' => __DIR__ . '/includes/libs/replacers/DoubleReplacer.php',
        'DummyLinker' => __DIR__ . '/includes/DummyLinker.php',
+       'DummySearchIndexFieldDefinition' => __DIR__ . '/includes/search/DummySearchIndexFieldDefinition.php',
        'DummyTermColorer' => __DIR__ . '/maintenance/term/MWTerm.php',
        'Dump7ZipOutput' => __DIR__ . '/includes/export/Dump7ZipOutput.php',
        'DumpBZip2Output' => __DIR__ . '/includes/export/DumpBZip2Output.php',
@@ -857,6 +858,7 @@ $wgAutoloadLocalClasses = [
        'MediaWiki\\Logger\\NullSpi' => __DIR__ . '/includes/debug/logger/NullSpi.php',
        'MediaWiki\\Logger\\Spi' => __DIR__ . '/includes/debug/logger/Spi.php',
        'MediaWiki\\MediaWikiServices' => __DIR__ . '/includes/MediaWikiServices.php',
+       'MediaWiki\\Search\\ParserOutputSearchDataExtractor' => __DIR__ . '/includes/search/ParserOutputSearchDataExtractor.php',
        'MediaWiki\\Services\\CannotReplaceActiveServiceException' => __DIR__ . '/includes/Services/CannotReplaceActiveServiceException.php',
        'MediaWiki\\Services\\ContainerDisabledException' => __DIR__ . '/includes/Services/ContainerDisabledException.php',
        'MediaWiki\\Services\\DestructibleService' => __DIR__ . '/includes/Services/DestructibleService.php',
index f5a37d4..eb3040c 100644 (file)
@@ -163,6 +163,9 @@ class OutputPage extends ContextSource {
        /** @var string */
        private $rlUserModuleState;
 
+       /** @var array */
+       private $rlExemptStyleModules;
+
        /** @var array */
        protected $mJsConfigVars = [];
 
@@ -2660,30 +2663,64 @@ class OutputPage extends ContextSource {
        public function getRlClient() {
                if ( !$this->rlClient ) {
                        $context = $this->getRlClientContext();
-                       $userModule = $this->getResourceLoader()->getModule( 'user' );
-                       // Manually handled by getBottomScripts()
-                       $userState = $userModule->isKnownEmpty( $context ) && !$this->isUserModulePreview()
-                               ? 'ready'
-                               : 'loading';
-                       $this->rlUserModuleState = $userState;
-
+                       $rl = $this->getResourceLoader();
                        $this->addModules( [
                                'user.options',
                                'user.tokens',
                        ] );
+                       $this->addModuleStyles( [
+                               'site.styles',
+                               'noscript',
+                               'user.styles',
+                               'user.cssprefs',
+                       ] );
+
+                       // Prepare exempt modules for buildExemptModules()
+                       $exemptGroups = [ 'site' => [], 'noscript' => [], 'private' => [], 'user' => [] ];
+                       $exemptStates = [];
+                       $moduleStyles = array_filter( $this->getModuleStyles( /*filter*/ true ),
+                               function ( $name ) use ( $rl, $context, &$exemptGroups, &$exemptStates ) {
+                                       $module = $rl->getModule( $name );
+                                       if ( $module ) {
+                                               $group = $module->getGroup();
+                                               if ( $name === 'user.styles' && $this->isUserCssPreview() ) {
+                                                       $exemptStates[$name] = 'ready';
+                                                       // Special case in buildExemptModules()
+                                                       return false;
+                                               }
+                                               if ( $name === 'site.styles' ) {
+                                                       // HACK: Technically, 'site.styles' isn't in a separate request group.
+                                                       // But, in order to ensure its styles are in the right position,
+                                                       // pretend it's in a group called 'site'.
+                                                       $group = 'site';
+                                               }
+                                               if ( isset( $exemptGroups[$group] ) ) {
+                                                       $exemptStates[$name] = 'ready';
+                                                       if ( !$module->isKnownEmpty( $context ) ) {
+                                                               // E.g. Don't output empty <styles>
+                                                               $exemptGroups[$group][] = $name;
+                                                       }
+                                                       return false;
+                                               }
+                                       }
+                                       return true;
+                               }
+                       );
+                       $this->rlExemptStyleModules = $exemptGroups;
+
+                       // Manually handled by getBottomScripts()
+                       $userModule = $rl->getModule( 'user' );
+                       $userState = $userModule->isKnownEmpty( $context ) && !$this->isUserJsPreview()
+                               ? 'ready'
+                               : 'loading';
+                       $this->rlUserModuleState = $exemptStates['user'] = $userState;
+
                        $rlClient = new ResourceLoaderClientHtml( $context, $this->getTarget() );
                        $rlClient->setConfig( $this->getJSVars() );
                        $rlClient->setModules( $this->getModules( /*filter*/ true ) );
-                       $rlClient->setModuleStyles( $this->getModuleStyles( /*filter*/ true ) );
+                       $rlClient->setModuleStyles( $moduleStyles );
                        $rlClient->setModuleScripts( $this->getModuleScripts( /*filter*/ true ) );
-                       $rlClient->setExemptStates( [
-                               'user' => $userState,
-                               // Manually handled by buildExemptModules() and getBottomScripts()
-                               'site.styles' => 'ready',
-                               'noscript' => 'ready',
-                               'user.cssprefs' => 'ready',
-                               'user.styles' => 'ready',
-                       ] );
+                       $rlClient->setExemptStates( $exemptStates );
                        $this->rlClient = $rlClient;
                }
                return $this->rlClient;
@@ -2813,8 +2850,7 @@ class OutputPage extends ContextSource {
                return WrappedString::join( "\n", $chunks );
        }
 
-       /** @return bool */
-       private function isUserModulePreview() {
+       private function isUserJsPreview() {
                return $this->getConfig()->get( 'AllowUserJs' )
                        && $this->getUser()->isLoggedIn()
                        && $this->getTitle()
@@ -2822,6 +2858,14 @@ class OutputPage extends ContextSource {
                        && $this->userCanPreview();
        }
 
+       private function isUserCssPreview() {
+               return $this->getConfig()->get( 'AllowUserCss' )
+                       && $this->getUser()->isLoggedIn()
+                       && $this->getTitle()
+                       && $this->getTitle()->isCssSubpage()
+                       && $this->userCanPreview();
+       }
+
        /**
         * JS stuff to put at the bottom of the `<body>`. These are modules with position 'bottom',
         * legacy scripts ($this->mScripts), and user JS.
@@ -2841,7 +2885,7 @@ class OutputPage extends ContextSource {
                //   ensures execution is scheduled after the "site" module.
                // - Don't load if module state is already resolved as "ready".
                if ( $this->rlUserModuleState === 'loading' ) {
-                       if ( $this->isUserModulePreview() ) {
+                       if ( $this->isUserJsPreview() ) {
                                $chunks[] = $this->makeResourceLoaderLink( 'user', ResourceLoaderModule::TYPE_COMBINED,
                                        [ 'excludepage' => $this->getTitle()->getPrefixedDBkey() ]
                                );
@@ -3414,19 +3458,11 @@ class OutputPage extends ContextSource {
 
                $resourceLoader = $this->getResourceLoader();
                $chunks = [];
-               // Things that should be appended after the other link and style chunks
+               // Things that go after the ResourceLoaderDynamicStyles marker
                $append = [];
-               $moduleStyles = [
-                       'site.styles',
-                       'noscript'
-               ];
 
-               // Exempt 'user' styles module.
-               // - May need excludepages for live preview.
-               // - Position after ResourceLoaderDynamicStyles marker
-               if ( $this->getConfig()->get( 'AllowUserCss' ) && $this->getTitle()->isCssSubpage()
-                       && $this->userCanPreview()
-               ) {
+               // Exempt 'user' styles module (may need 'excludepages' for live preview)
+               if ( $this->isUserCssPreview() ) {
                        $append[] = $this->makeResourceLoaderLink(
                                'user.styles',
                                ResourceLoaderModule::TYPE_STYLES,
@@ -3440,60 +3476,26 @@ class OutputPage extends ContextSource {
                                $previewedCSS = CSSJanus::transform( $previewedCSS, true, false );
                        }
                        $append[] = Html::inlineStyle( $previewedCSS );
-               } else {
-                       $module = $this->getResourceLoader()->getModule( 'user.styles' );
-                       if ( !$module->isKnownEmpty( $this->getRlClientContext() ) ) {
-                               // Load styles normally
-                               $moduleStyles[] = 'user.styles';
-                       }
-               }
-
-               // Exempt 'user.cssprefs' module
-               // - Position after ResourceLoaderDynamicStyles marker
-               $moduleStyles[] = 'user.cssprefs';
-
-               $groups = [
-                       'other' => [],
-                       'site' => [],
-                       'noscript' => [],
-                       'private' => [],
-                       'user' => [],
-               ];
-               foreach ( $moduleStyles as $name ) {
-                       $module = $resourceLoader->getModule( $name );
-                       if ( !$module || $module->isKnownEmpty( $this->getRlClientContext() ) ) {
-                               // E.g. Don't output empty <styles> for user.cssprefs
-                               continue;
-                       }
-                       if ( $name === 'site.styles' ) {
-                               // HACK: Technically, the 'site.styles' module isn't in a separate request group.
-                               // But, in order to ensure its styles are in the right position after the marker,
-                               // pretend it's in a group called 'site'.
-                               $groups['site'][] = $name;
-                               continue;
-                       }
-                       $group = $module->getGroup();
-                       // Use "other" in case. All exempt modules are in one of the known groups though.
-                       $groups[isset( $groups[$group] ) ? $group : 'other'][] = $name;
                }
 
-               // We want site, private and user styles to override dynamically added
-               // styles from modules, but we want dynamically added styles to override
-               // statically added styles from other modules. So the order has to be
-               // other, dynamic, site, private, user. Add statically added styles for
-               // other modules
+               // We want site, private and user styles to override dynamically added styles from
+               // general modules, but we want dynamically added styles to override statically added
+               // style modules. So the order has to be:
+               // - page style modules (formatted by ResourceLoaderClientHtml::getHeadHtml())
+               // - dynamically loaded styles (added by mw.loader before ResourceLoaderDynamicStyles)
+               // - ResourceLoaderDynamicStyles marker
+               // - site/private/user styles
 
                // Add legacy styles added through addStyle()/addInlineStyle() here
                $chunks[] = implode( '', $this->buildCssLinksArray() ) . $this->mInlineStyles;
 
-               // Client-side mw.loader will inject dynamic styles before this marker.
                $chunks[] = Html::element(
                        'meta',
                        [ 'name' => 'ResourceLoaderDynamicStyles', 'content' => '' ]
                );
 
-               foreach ( [ 'other', 'site', 'noscript', 'private', 'user' ] as $group ) {
-                       $chunks[] = $this->makeResourceLoaderLink( $groups[$group],
+               foreach ( $this->rlExemptStyleModules as $group => $moduleNames ) {
+                       $chunks[] = $this->makeResourceLoaderLink( $moduleNames,
                                ResourceLoaderModule::TYPE_STYLES
                        );
                }
@@ -3813,9 +3815,6 @@ class OutputPage extends ContextSource {
                        'oojs-ui.styles.textures',
                        'mediawiki.widgets.styles',
                ] );
-               // Used by 'skipFunction' of the four 'oojs-ui.styles.*' modules. Please don't treat this as a
-               // public API or you'll be severely disappointed when T87871 is fixed and it disappears.
-               $this->addMeta( 'X-OOUI-PHP', '1' );
        }
 
        /**
index c970f93..a4f54ee 100644 (file)
@@ -157,8 +157,13 @@ class ApiAuthManagerHelper {
 
                // Collect the fields for all the requests
                $fields = [];
+               $sensitive = [];
                foreach ( $reqs as $req ) {
-                       $fields += (array)$req->getFieldInfo();
+                       $info = (array)$req->getFieldInfo();
+                       $fields += $info;
+                       $sensitive += array_filter( $info, function ( $opts ) {
+                               return !empty( $opts['sensitive'] );
+                       } );
                }
 
                // Extract the request data for the fields and mark those request
@@ -166,6 +171,16 @@ class ApiAuthManagerHelper {
                $data = array_intersect_key( $this->module->getRequest()->getValues(), $fields );
                $this->module->getMain()->markParamsUsed( array_keys( $data ) );
 
+               if ( $sensitive ) {
+                       try {
+                               $this->module->requirePostedParameters( array_keys( $sensitive ), 'noprefix' );
+                       } catch ( UsageException $ex ) {
+                               // Make this a warning for now, upgrade to an error in 1.29.
+                               $this->module->setWarning( $ex->getMessage() );
+                               $this->module->logFeatureUsage( $this->module->getModuleName() . '-params-in-query-string' );
+                       }
+               }
+
                return AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data );
        }
 
@@ -326,6 +341,7 @@ class ApiAuthManagerHelper {
                        $this->formatMessage( $ret, 'label', $field['label'] );
                        $this->formatMessage( $ret, 'help', $field['help'] );
                        $ret['optional'] = !empty( $field['optional'] );
+                       $ret['sensitive'] = !empty( $field['sensitive'] );
 
                        $retFields[$name] = $ret;
                }
index 4a1a520..fcb748c 100644 (file)
@@ -776,6 +776,39 @@ abstract class ApiBase extends ContextSource {
                }
        }
 
+       /**
+        * Die if any of the specified parameters were found in the query part of
+        * the URL rather than the post body.
+        * @since 1.28
+        * @param string[] $params Parameters to check
+        * @param string $prefix Set to 'noprefix' to skip calling $this->encodeParamName()
+        */
+       public function requirePostedParameters( $params, $prefix = 'prefix' ) {
+               // Skip if $wgDebugAPI is set or we're in internal mode
+               if ( $this->getConfig()->get( 'DebugAPI' ) || $this->getMain()->isInternalMode() ) {
+                       return;
+               }
+
+               $queryValues = $this->getRequest()->getQueryValues();
+               $badParams = [];
+               foreach ( $params as $param ) {
+                       if ( $prefix !== 'noprefix' ) {
+                               $param = $this->encodeParamName( $param );
+                       }
+                       if ( array_key_exists( $param, $queryValues ) ) {
+                               $badParams[] = $param;
+                       }
+               }
+
+               if ( $badParams ) {
+                       $this->dieUsage(
+                               'The following parameters were found in the query string, but must be in the POST body: '
+                                       . join( ', ', $badParams ),
+                               'mustpostparams'
+                       );
+               }
+       }
+
        /**
         * Callback function used in requireOnlyOneParameter to check whether required parameters are set
         *
@@ -2197,7 +2230,7 @@ abstract class ApiBase extends ContextSource {
         * analysis.
         * @param string $feature Feature being used.
         */
-       protected function logFeatureUsage( $feature ) {
+       public function logFeatureUsage( $feature ) {
                $request = $this->getRequest();
                $s = '"' . addslashes( $feature ) . '"' .
                        ' "' . wfUrlencode( str_replace( ' ', '_', $this->getUser()->getName() ) ) . '"' .
index 28937f7..9bc0b3a 100644 (file)
@@ -70,6 +70,14 @@ class ApiLogin extends ApiBase {
                        return;
                }
 
+               try {
+                       $this->requirePostedParameters( [ 'password', 'token' ] );
+               } catch ( UsageException $ex ) {
+                       // Make this a warning for now, upgrade to an error in 1.29.
+                       $this->setWarning( $ex->getMessage() );
+                       $this->logFeatureUsage( 'login-params-in-query-string' );
+               }
+
                $params = $this->extractRequestParams();
 
                $result = [];
index 565e829..22b079d 100644 (file)
@@ -1103,18 +1103,7 @@ class ApiMain extends ApiBase {
                                $this->dieUsageMsg( [ 'missingparam', 'token' ] );
                        }
 
-                       if ( !$this->getConfig()->get( 'DebugAPI' ) &&
-                               array_key_exists(
-                                       $module->encodeParamName( 'token' ),
-                                       $this->getRequest()->getQueryValues()
-                               )
-                       ) {
-                               $this->dieUsage(
-                                       "The '{$module->encodeParamName( 'token' )}' parameter was " .
-                                               'found in the query string, but must be in the POST body',
-                                       'mustposttoken'
-                               );
-                       }
+                       $module->requirePostedParameters( [ 'token' ] );
 
                        if ( !$module->validateToken( $moduleParams['token'], $moduleParams ) ) {
                                $this->dieUsageMsg( 'sessionfailure' );
index 3810e90..236fb9e 100644 (file)
@@ -53,6 +53,7 @@ class ApiQueryBacklinksprop extends ApiQueryGeneratorBase {
                        'code' => 'lh',
                        'prefix' => 'pl',
                        'linktable' => 'pagelinks',
+                       'indexes' => [ 'pl_namespace', 'pl_backlinks_namespace' ],
                        'from_namespace' => true,
                        'showredirects' => true,
                ],
@@ -60,6 +61,7 @@ class ApiQueryBacklinksprop extends ApiQueryGeneratorBase {
                        'code' => 'ti',
                        'prefix' => 'tl',
                        'linktable' => 'templatelinks',
+                       'indexes' => [ 'tl_namespace', 'tl_backlinks_namespace' ],
                        'from_namespace' => true,
                        'showredirects' => true,
                ],
@@ -67,6 +69,7 @@ class ApiQueryBacklinksprop extends ApiQueryGeneratorBase {
                        'code' => 'fu',
                        'prefix' => 'il',
                        'linktable' => 'imagelinks',
+                       'indexes' => [ 'il_to', 'il_backlinks_namespace' ],
                        'from_namespace' => true,
                        'to_namespace' => NS_FILE,
                        'exampletitle' => 'File:Example.jpg',
@@ -249,6 +252,18 @@ class ApiQueryBacklinksprop extends ApiQueryGeneratorBase {
                // Override any ORDER BY from above with what we calculated earlier.
                $this->addOption( 'ORDER BY', array_keys( $sortby ) );
 
+               // MySQL's optimizer chokes if we have too many values in "$bl_title IN
+               // (...)" and chooses the wrong index, so specify the correct index to
+               // use for the query. See T139056 for details.
+               if ( !empty( $settings['indexes'] ) ) {
+                       list( $idxNoFromNS, $idxWithFromNS ) = $settings['indexes'];
+                       if ( $params['namespace'] !== null && !empty( $settings['from_namespace'] ) ) {
+                               $this->addOption( 'USE INDEX', [ $settings['linktable'] => $idxWithFromNS ] );
+                       } else {
+                               $this->addOption( 'USE INDEX', [ $settings['linktable'] => $idxNoFromNS ] );
+                       }
+               }
+
                $this->addOption( 'LIMIT', $params['limit'] + 1 );
 
                $res = $this->select( __METHOD__ );
index fc2fd59..ac817ba 100644 (file)
@@ -274,11 +274,17 @@ class ApiUpload extends ApiBase {
                                        $this->dieStatusWithCode( $status, 'stashfailed' );
                                }
 
+                               // We can only get warnings like 'duplicate' after concatenating the chunks
+                               $warnings = $this->getApiWarnings();
+                               if ( $warnings ) {
+                                       $result['warnings'] = $warnings;
+                               }
+
                                // The fully concatenated file has a new filekey. So remove
                                // the old filekey and fetch the new one.
                                UploadBase::setSessionStatus( $this->getUser(), $filekey, false );
                                $this->mUpload->stash->removeFile( $filekey );
-                               $filekey = $this->mUpload->getLocalFile()->getFileKey();
+                               $filekey = $this->mUpload->getStashFile()->getFileKey();
 
                                $result['result'] = 'Success';
                        }
@@ -431,6 +437,12 @@ class ApiUpload extends ApiBase {
                        if ( isset( $progress['status']->value['verification'] ) ) {
                                $this->checkVerification( $progress['status']->value['verification'] );
                        }
+                       if ( isset( $progress['status']->value['warnings'] ) ) {
+                               $warnings = $this->transformWarnings( $progress['status']->value['warnings'] );
+                               if ( $warnings ) {
+                                       $progress['warnings'] = $warnings;
+                               }
+                       }
                        unset( $progress['status'] ); // remove Status object
                        $this->getResult()->addValue( null, $this->getModuleName(), $progress );
 
@@ -735,7 +747,7 @@ class ApiUpload extends ApiBase {
                        $this->mParams['text'] = $this->mParams['comment'];
                }
 
-               /** @var $file File */
+               /** @var $file LocalFile */
                $file = $this->mUpload->getLocalFile();
 
                // For preferences mode, we want to watch if 'watchdefault' is set,
index 80eec50..4b42964 100644 (file)
@@ -25,7 +25,8 @@
                        "Lbayle",
                        "Verdy p",
                        "Yasten",
-                       "Trial"
+                       "Trial",
+                       "Pols12"
                ]
        },
        "apihelp-main-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:API:Main_page|Documentation]]\n* [[mw:API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Liste de diffusion]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Annonces de l’API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bogues et demandes]\n</div>\n<strong>État :</strong> Toutes les fonctionnalités affichées sur cette page devraient fonctionner, mais l’API est encore en cours de développement et peut changer à tout moment. Inscrivez-vous à [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ la liste de diffusion mediawiki-api-announce] pour être informé des mises à jour.\n\n<strong>Requêtes erronées :</strong> Si des requêtes erronées sont envoyées à l’API, un en-tête HTTP sera renvoyé avec la clé « MediaWiki-API-Error ». La valeur de cet en-tête et le code d’erreur renvoyé prendront la même valeur. Pour plus d’information, voyez [[mw:API:Errors_and_warnings|API: Errors and warnings]].\n\n<strong>Test :</strong> Pour faciliter le test des requêtes de l’API, voyez [[Special:ApiSandbox]].",
        "apihelp-stashedit-param-section": "Numéro de section. <kbd>0</kbd> pour la section du haut, <kbd>new</kbd> pour une nouvelle section.",
        "apihelp-stashedit-param-sectiontitle": "Le titre pour une nouvelle section.",
        "apihelp-stashedit-param-text": "Contenu de la page.",
-       "apihelp-stashedit-param-stashedtexthash": "A substituer par le hachage du contenu de la page venant d’une réserve antérieure.",
+       "apihelp-stashedit-param-stashedtexthash": "Empreinte du contenu de la page venant d’une réserve préalable à utiliser à la place.",
        "apihelp-stashedit-param-contentmodel": "Modèle de contenu du nouveau contenu.",
        "apihelp-stashedit-param-contentformat": "Format de sérialisation de contenu utilisé pour le texte saisi.",
        "apihelp-stashedit-param-baserevid": "ID de révision de la révision de base.",
index 037aff4..860b7a7 100644 (file)
        "apihelp-query+search-paramvalue-prop-snippet": "Adds a parsed snippet of the page.",
        "apihelp-query+search-paramvalue-prop-titlesnippet": "Adds a parsed snippet of the page title.",
        "apihelp-query+search-paramvalue-prop-redirectsnippet": "添加被解析的重定向标题的片段。",
-       "apihelp-query+search-paramvalue-prop-redirecttitle": "Adds the title of the matching redirect.",
+       "apihelp-query+search-paramvalue-prop-redirecttitle": "添加匹配的重定向的标题。",
        "apihelp-query+search-paramvalue-prop-sectionsnippet": "Adds a parsed snippet of the matching section title.",
        "apihelp-query+search-paramvalue-prop-sectiontitle": "Adds the title of the matching section.",
        "apihelp-query+search-paramvalue-prop-categorysnippet": "Adds a parsed snippet of the matching category.",
        "apihelp-query+search-param-enablerewrites": "启用内部查询重写。一些搜索后端可以重写查询到它认为会给出更好结果的地方,例如纠正拼写错误。",
        "apihelp-query+search-example-simple": "搜索<kbd>meaning</kbd>。",
        "apihelp-query+search-example-text": "搜索文本<kbd>meaning</kbd>。",
-       "apihelp-query+search-example-generator": "è\8e·å¾\97有关搜索<kbd>meaning</kbd>返回页面的页面信息。",
+       "apihelp-query+search-example-generator": "è\8e·å\8f\96有关搜索<kbd>meaning</kbd>返回页面的页面信息。",
        "apihelp-query+siteinfo-description": "返回有关网站的一般信息。",
        "apihelp-query+siteinfo-param-prop": "要获取的信息:",
        "apihelp-query+siteinfo-paramvalue-prop-general": "全部系统信息。",
index b8c536e..acdc01b 100644 (file)
@@ -1688,13 +1688,9 @@ class AuthManager implements LoggerAwareInterface {
                        $logEntry->setParameters( [
                                '4::userid' => $user->getId(),
                        ] );
-                       $logid = $logEntry->insert();
+                       $logEntry->insert();
                }
 
-               // Commit database changes, so even if something else later blows up
-               // the newly-created user doesn't get lost.
-               wfGetLBFactory()->commitMasterChanges( __METHOD__ );
-
                $trxProfiler->setSilenced( false );
 
                if ( $login ) {
index f6f949e..2474b8b 100644 (file)
@@ -102,6 +102,8 @@ abstract class AuthenticationRequest {
         *  - label: (Message) Text suitable for a label in an HTML form
         *  - help: (Message) Text suitable as a description of what the field is
         *  - optional: (bool) If set and truthy, the field may be left empty
+        *  - sensitive: (bool) If set and truthy, the field is considered sensitive. Code using the
+        *      request should avoid exposing the value of the field.
         *
         * @return array As above
         */
@@ -315,6 +317,8 @@ abstract class AuthenticationRequest {
                                        $options['optional'] = !empty( $options['optional'] );
                                }
 
+                               $options['sensitive'] = !empty( $options['sensitive'] );
+
                                if ( !array_key_exists( $name, $merged ) ) {
                                        $merged[$name] = $options;
                                } elseif ( $merged[$name]['type'] !== $options['type'] ) {
@@ -333,6 +337,7 @@ abstract class AuthenticationRequest {
                                        }
 
                                        $merged[$name]['optional'] = $merged[$name]['optional'] && $options['optional'];
+                                       $merged[$name]['sensitive'] = $merged[$name]['sensitive'] || $options['sensitive'];
 
                                        // No way to merge 'value', 'image', 'help', or 'label', so just use
                                        // the value from the first request.
index d32640e..a82f018 100644 (file)
@@ -50,15 +50,16 @@ class EmailNotificationSecondaryAuthenticationProvider
                        && $user->getEmail()
                        && !$this->manager->getAuthenticationSessionData( 'no-email' )
                ) {
-                       $status = $user->sendConfirmationMail();
-                       $user->saveSettings();
-                       if ( $status->isGood() ) {
-                               // TODO show 'confirmemail_oncreate' success message
-                       } else {
-                               // TODO show 'confirmemail_sendfailed' error message
-                               $this->logger->warning( 'Could not send confirmation email: ' .
-                                       $status->getWikiText( false, false, 'en' ) );
-                       }
+                       // TODO show 'confirmemail_oncreate'/'confirmemail_sendfailed' message
+                       wfGetDB( DB_MASTER )->onTransactionIdle( function () use ( $user ) {
+                               $user = $user->getInstanceForUpdate();
+                               $status = $user->sendConfirmationMail();
+                               $user->saveSettings();
+                               if ( !$status->isGood() ) {
+                                       $this->logger->warning( 'Could not send confirmation email: ' .
+                                               $status->getWikiText( false, false, 'en' ) );
+                               }
+                       } );
                }
 
                return AuthenticationResponse::newPass();
index 187c29a..8550f3e 100644 (file)
@@ -53,6 +53,7 @@ class PasswordAuthenticationRequest extends AuthenticationRequest {
                                'type' => 'password',
                                'label' => wfMessage( $passwordLabel ),
                                'help' => wfMessage( 'authmanager-password-help' ),
+                               'sensitive' => true,
                        ],
                ];
 
@@ -68,6 +69,7 @@ class PasswordAuthenticationRequest extends AuthenticationRequest {
                                'type' => 'password',
                                'label' => wfMessage( $retypeLabel ),
                                'help' => wfMessage( 'authmanager-retype-help' ),
+                               'sensitive' => true,
                        ];
                }
 
index 46cbab5..ed94c1a 100644 (file)
@@ -303,7 +303,11 @@ class TemporaryPasswordPrimaryAuthenticationProvider
                );
 
                if ( $sendMail ) {
-                       $this->sendPasswordResetEmail( $req );
+                       // Send email after DB commit
+                       $dbw->onTransactionIdle( function () use ( $req ) {
+                               /** @var TemporaryPasswordAuthenticationRequest $req */
+                               $this->sendPasswordResetEmail( $req );
+                       } );
                }
        }
 
@@ -370,7 +374,10 @@ class TemporaryPasswordPrimaryAuthenticationProvider
                $this->providerChangeAuthenticationData( $req );
 
                if ( $mailpassword ) {
-                       $this->sendNewAccountEmail( $user, $creator, $req->password );
+                       // Send email after DB commit
+                       wfGetDB( DB_MASTER )->onTransactionIdle( function () use ( $user, $creator, $req ) {
+                               $this->sendNewAccountEmail( $user, $creator, $req->password );
+                       } );
                }
 
                return $mailpassword ? 'byemail' : null;
index 7184980..41fdef5 100644 (file)
@@ -1,4 +1,7 @@
 <?php
+
+use MediaWiki\Search\ParserOutputSearchDataExtractor;
+
 /**
  * Base class for content handling.
  *
@@ -1156,62 +1159,19 @@ abstract class ContentHandler {
         *
         * @param string $event Event name
         * @param array $args Parameters passed to hook functions
-        * @param bool $warn Whether to log a warning.
-        *                    Default to self::$enableDeprecationWarnings.
-        *                    May be set to false for testing.
+        * @param string|null $deprecatedVersion Emit a deprecation notice
+        *   when the hook is run for the provided version
         *
         * @return bool True if no handler aborted the hook
-        *
-        * @see ContentHandler::$enableDeprecationWarnings
         */
        public static function runLegacyHooks( $event, $args = [],
-               $warn = null
+               $deprecatedVersion = null
        ) {
 
-               if ( $warn === null ) {
-                       $warn = self::$enableDeprecationWarnings;
-               }
-
                if ( !Hooks::isRegistered( $event ) ) {
                        return true; // nothing to do here
                }
 
-               if ( $warn ) {
-                       // Log information about which handlers are registered for the legacy hook,
-                       // so we can find and fix them.
-
-                       $handlers = Hooks::getHandlers( $event );
-                       $handlerInfo = [];
-
-                       MediaWiki\suppressWarnings();
-
-                       foreach ( $handlers as $handler ) {
-                               if ( is_array( $handler ) ) {
-                                       if ( is_object( $handler[0] ) ) {
-                                               $info = get_class( $handler[0] );
-                                       } else {
-                                               $info = $handler[0];
-                                       }
-
-                                       if ( isset( $handler[1] ) ) {
-                                               $info .= '::' . $handler[1];
-                                       }
-                               } elseif ( is_object( $handler ) ) {
-                                       $info = get_class( $handler[0] );
-                                       $info .= '::on' . $event;
-                               } else {
-                                       $info = $handler;
-                               }
-
-                               $handlerInfo[] = $info;
-                       }
-
-                       MediaWiki\restoreWarnings();
-
-                       wfWarn( "Using obsolete hook $event via ContentHandler::runLegacyHooks()! Handlers: " .
-                               implode( ', ', $handlerInfo ), 2 );
-               }
-
                // convert Content objects to text
                $contentObjects = [];
                $contentTexts = [];
@@ -1229,7 +1189,7 @@ abstract class ContentHandler {
                }
 
                // call the hook functions
-               $ok = Hooks::run( $event, $args );
+               $ok = Hooks::run( $event, $args, $deprecatedVersion );
 
                // see if the hook changed the text
                foreach ( $contentTexts as $k => $orig ) {
@@ -1251,24 +1211,40 @@ abstract class ContentHandler {
 
        /**
         * Get fields definition for search index
+        *
+        * @todo Expose title, redirect, namespace, text, source_text, text_bytes
+        *       field mappings here. (see T142670 and T143409)
+        *
         * @param SearchEngine $engine
         * @return SearchIndexField[] List of fields this content handler can provide.
         * @since 1.28
         */
        public function getFieldsForSearchIndex( SearchEngine $engine ) {
-               /* Default fields:
-               /*
-                * namespace
-                * namespace_text
-                * redirect
-                * source_text
-                * suggest
-                * timestamp
-                * title
-                * text
-                * text_bytes
-                */
-               return [];
+               $fields['category'] = $engine->makeSearchFieldMapping(
+                       'category',
+                       SearchIndexField::INDEX_TYPE_TEXT
+               );
+
+               $fields['category']->setFlag( SearchIndexField::FLAG_CASEFOLD );
+
+               $fields['external_link'] = $engine->makeSearchFieldMapping(
+                       'external_link',
+                       SearchIndexField::INDEX_TYPE_KEYWORD
+               );
+
+               $fields['outgoing_link'] = $engine->makeSearchFieldMapping(
+                       'outgoing_link',
+                       SearchIndexField::INDEX_TYPE_KEYWORD
+               );
+
+               $fields['template'] = $engine->makeSearchFieldMapping(
+                       'template',
+                       SearchIndexField::INDEX_TYPE_KEYWORD
+               );
+
+               $fields['template']->setFlag( SearchIndexField::FLAG_CASEFOLD );
+
+               return $fields;
        }
 
        /**
@@ -1298,16 +1274,26 @@ abstract class ContentHandler {
         */
        public function getDataForSearchIndex( WikiPage $page, ParserOutput $output,
                                               SearchEngine $engine ) {
-               $fields = [];
+               $fieldData = [];
                $content = $page->getContent();
+
                if ( $content ) {
+                       $searchDataExtractor = new ParserOutputSearchDataExtractor();
+
+                       $fieldData['category'] = $searchDataExtractor->getCategories( $output );
+                       $fieldData['external_link'] = $searchDataExtractor->getExternalLinks( $output );
+                       $fieldData['outgoing_link'] = $searchDataExtractor->getOutgoingLinks( $output );
+                       $fieldData['template'] = $searchDataExtractor->getTemplates( $output );
+
                        $text = $content->getTextForSearchIndex();
-                       $fields['text'] = $text;
-                       $fields['source_text'] = $text;
-                       $fields['text_bytes'] = $content->getSize();
+
+                       $fieldData['text'] = $text;
+                       $fieldData['source_text'] = $text;
+                       $fieldData['text_bytes'] = $content->getSize();
                }
-               Hooks::run( 'SearchDataForIndex', [ &$fields, $this, $page, $output, $engine ] );
-               return $fields;
+
+               Hooks::run( 'SearchDataForIndex', [ &$fieldData, $this, $page, $output, $engine ] );
+               return $fieldData;
        }
 
        /**
index e83c213..9768d36 100644 (file)
@@ -58,50 +58,6 @@ class WikiTextStructure {
                $this->parserOutput = $parserOutput;
        }
 
-       /**
-        * Get categories in the text.
-        * @return string[]
-        */
-       public function categories() {
-               $categories = [];
-               foreach ( array_keys( $this->parserOutput->getCategories() ) as $key ) {
-                       $categories[] = Category::newFromName( $key )->getTitle()->getText();
-               }
-               return $categories;
-       }
-
-       /**
-        * Get outgoing links.
-        * @return string[]
-        */
-       public function outgoingLinks() {
-               $outgoingLinks = [];
-               foreach ( $this->parserOutput->getLinks() as $linkedNamespace => $namespaceLinks ) {
-                       foreach ( array_keys( $namespaceLinks ) as $linkedDbKey ) {
-                               $outgoingLinks[] =
-                                       Title::makeTitle( $linkedNamespace, $linkedDbKey )->getPrefixedDBkey();
-                       }
-               }
-               return $outgoingLinks;
-       }
-
-       /**
-        * Get templates in the text.
-        * @return string[]
-        */
-       public function templates() {
-               $templates = [];
-               foreach ( $this->parserOutput->getTemplates() as $tNS => $templatesInNS ) {
-                       foreach ( array_keys( $templatesInNS ) as $tDbKey ) {
-                               $templateTitle = Title::makeTitleSafe( $tNS, $tDbKey );
-                               if ( $templateTitle && $templateTitle->exists() ) {
-                                       $templates[] = $templateTitle->getPrefixedText();
-                               }
-                       }
-               }
-               return $templates;
-       }
-
        /**
         * Get headings on the page.
         * @return string[]
index 9baf643..1c46d28 100644 (file)
@@ -111,13 +111,6 @@ class WikitextContentHandler extends TextContentHandler {
        public function getFieldsForSearchIndex( SearchEngine $engine ) {
                $fields = parent::getFieldsForSearchIndex( $engine );
 
-               $fields['category'] =
-                       $engine->makeSearchFieldMapping( 'category', SearchIndexField::INDEX_TYPE_TEXT );
-               $fields['category']->setFlag( SearchIndexField::FLAG_CASEFOLD );
-
-               $fields['external_link'] =
-                       $engine->makeSearchFieldMapping( 'external_link', SearchIndexField::INDEX_TYPE_KEYWORD );
-
                $fields['heading'] =
                        $engine->makeSearchFieldMapping( 'heading', SearchIndexField::INDEX_TYPE_TEXT );
                $fields['heading']->setFlag( SearchIndexField::FLAG_SCORING );
@@ -130,13 +123,6 @@ class WikitextContentHandler extends TextContentHandler {
                $fields['opening_text']->setFlag( SearchIndexField::FLAG_SCORING |
                                                  SearchIndexField::FLAG_NO_HIGHLIGHT );
 
-               $fields['outgoing_link'] =
-                       $engine->makeSearchFieldMapping( 'outgoing_link', SearchIndexField::INDEX_TYPE_KEYWORD );
-
-               $fields['template'] =
-                       $engine->makeSearchFieldMapping( 'template', SearchIndexField::INDEX_TYPE_KEYWORD );
-               $fields['template']->setFlag( SearchIndexField::FLAG_CASEFOLD );
-
                // FIXME: this really belongs in separate file handler but files
                // do not have separate handler. Sadness.
                $fields['file_text'] =
@@ -154,7 +140,11 @@ class WikitextContentHandler extends TextContentHandler {
        protected function getFileText( Title $title ) {
                $file = wfLocalFile( $title );
                if ( $file && $file->exists() ) {
-                       return $file->getHandler()->getEntireText( $file );
+                       $handler = $file->getHandler();
+                       if ( !$handler ) {
+                               return null;
+                       }
+                       return $handler->getEntireText( $file );
                }
 
                return null;
@@ -165,11 +155,7 @@ class WikitextContentHandler extends TextContentHandler {
                $fields = parent::getDataForSearchIndex( $page, $parserOutput, $engine );
 
                $structure = new WikiTextStructure( $parserOutput );
-               $fields['external_link'] = array_keys( $parserOutput->getExternalLinks() );
-               $fields['category'] = $structure->categories();
                $fields['heading'] = $structure->headings();
-               $fields['outgoing_link'] = $structure->outgoingLinks();
-               $fields['template'] = $structure->templates();
                // text fields
                $fields['opening_text'] = $structure->getOpeningText();
                $fields['text'] = $structure->getMainText(); // overwrites one from ContentHandler
index 53862b9..4a78363 100644 (file)
@@ -445,7 +445,7 @@ class DBConnRef implements IDatabase {
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
-       public function begin( $fname = __METHOD__ ) {
+       public function begin( $fname = __METHOD__, $mode = IDatabase::TRANSACTION_EXPLICIT ) {
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
index 78975ff..933bea6 100644 (file)
@@ -815,7 +815,7 @@ abstract class DatabaseBase implements IDatabase {
                if ( !$this->mTrxLevel && $this->getFlag( DBO_TRX )
                        && $this->isTransactableQuery( $sql )
                ) {
-                       $this->begin( __METHOD__ . " ($fname)" );
+                       $this->begin( __METHOD__ . " ($fname)", self::TRANSACTION_INTERNAL );
                        $this->mTrxAutomatic = true;
                }
 
@@ -2203,7 +2203,7 @@ abstract class DatabaseBase implements IDatabase {
 
                $useTrx = !$this->mTrxLevel;
                if ( $useTrx ) {
-                       $this->begin( $fname );
+                       $this->begin( $fname, self::TRANSACTION_INTERNAL );
                }
                try {
                        # Update any existing conflicting row(s)
@@ -2221,7 +2221,7 @@ abstract class DatabaseBase implements IDatabase {
                        throw $e;
                }
                if ( $useTrx ) {
-                       $this->commit( $fname );
+                       $this->commit( $fname, self::TRANSACTION_INTERNAL );
                }
 
                return $ok;
@@ -2520,7 +2520,7 @@ abstract class DatabaseBase implements IDatabase {
                        $this->mTrxPreCommitCallbacks[] = [ $callback, wfGetCaller() ];
                } else {
                        // If no transaction is active, then make one for this callback
-                       $this->begin( __METHOD__ );
+                       $this->begin( __METHOD__, self::TRANSACTION_INTERNAL );
                        try {
                                call_user_func( $callback );
                                $this->commit( __METHOD__ );
@@ -2628,7 +2628,7 @@ abstract class DatabaseBase implements IDatabase {
 
        final public function startAtomic( $fname = __METHOD__ ) {
                if ( !$this->mTrxLevel ) {
-                       $this->begin( $fname );
+                       $this->begin( $fname, self::TRANSACTION_INTERNAL );
                        $this->mTrxAutomatic = true;
                        // If DBO_TRX is set, a series of startAtomic/endAtomic pairs will result
                        // in all changes being in one transaction to keep requests transactional.
@@ -2658,51 +2658,36 @@ abstract class DatabaseBase implements IDatabase {
        final public function doAtomicSection( $fname, callable $callback ) {
                $this->startAtomic( $fname );
                try {
-                       call_user_func_array( $callback, [ $this, $fname ] );
+                       $res = call_user_func_array( $callback, [ $this, $fname ] );
                } catch ( Exception $e ) {
                        $this->rollback( $fname );
                        throw $e;
                }
                $this->endAtomic( $fname );
+
+               return $res;
        }
 
-       final public function begin( $fname = __METHOD__ ) {
-               if ( $this->mTrxLevel ) { // implicit commit
+       final public function begin( $fname = __METHOD__, $mode = self::TRANSACTION_EXPLICIT ) {
+               // Protect against mismatched atomic section, transaction nesting, and snapshot loss
+               if ( $this->mTrxLevel ) {
                        if ( $this->mTrxAtomicLevels ) {
-                               // If the current transaction was an automatic atomic one, then we definitely have
-                               // a problem. Same if there is any unclosed atomic level.
                                $levels = implode( ', ', $this->mTrxAtomicLevels );
-                               throw new DBUnexpectedError(
-                                       $this,
-                                       "Got explicit BEGIN from $fname while atomic section(s) $levels are open."
-                               );
+                               $msg = "$fname: Got explicit BEGIN while atomic section(s) $levels are open.";
+                               throw new DBUnexpectedError( $this, $msg );
                        } elseif ( !$this->mTrxAutomatic ) {
-                               // We want to warn about inadvertently nested begin/commit pairs, but not about
-                               // auto-committing implicit transactions that were started by query() via DBO_TRX
-                               throw new DBUnexpectedError(
-                                       $this,
-                                       "$fname: Transaction already in progress (from {$this->mTrxFname}), " .
-                                               " performing implicit commit!"
-                               );
-                       } elseif ( $this->mTrxDoneWrites ) {
-                               // The transaction was automatic and has done write operations
-                               throw new DBUnexpectedError(
-                                       $this,
-                                       "$fname: Automatic transaction with writes in progress" .
-                                               " (from {$this->mTrxFname}), performing implicit commit!\n"
-                               );
-                       }
-
-                       $this->runOnTransactionPreCommitCallbacks();
-                       $writeTime = $this->pendingWriteQueryDuration();
-                       $this->doCommit( $fname );
-                       if ( $this->mTrxDoneWrites ) {
-                               $this->mDoneWrites = microtime( true );
-                               $this->getTransactionProfiler()->transactionWritingOut(
-                                       $this->mServer, $this->mDBname, $this->mTrxShortId, $writeTime );
+                               $msg = "$fname: Explicit transaction already active (from {$this->mTrxFname}).";
+                               throw new DBUnexpectedError( $this, $msg );
+                       } else {
+                               // @TODO: make this an exception at some point
+                               $msg = "$fname: Implicit transaction already active (from {$this->mTrxFname}).";
+                               wfLogDBError( $msg );
+                               return; // join the main transaction set
                        }
-
-                       $this->runOnTransactionIdleCallbacks( self::TRIGGER_COMMIT );
+               } elseif ( $this->getFlag( DBO_TRX ) && $mode !== self::TRANSACTION_INTERNAL ) {
+                       // @TODO: make this an exception at some point
+                       wfLogDBError( "$fname: Implicit transaction expected (DBO_TRX set)." );
+                       return; // let any writes be in the main transaction
                }
 
                // Avoid fatals if close() was called
@@ -2742,7 +2727,7 @@ abstract class DatabaseBase implements IDatabase {
                        $levels = implode( ', ', $this->mTrxAtomicLevels );
                        throw new DBUnexpectedError(
                                $this,
-                               "Got COMMIT while atomic sections $levels are still open"
+                               "$fname: Got COMMIT while atomic sections $levels are still open."
                        );
                }
 
@@ -2752,18 +2737,17 @@ abstract class DatabaseBase implements IDatabase {
                        } elseif ( !$this->mTrxAutomatic ) {
                                throw new DBUnexpectedError(
                                        $this,
-                                       "$fname: Flushing an explicit transaction, getting out of sync!"
+                                       "$fname: Flushing an explicit transaction, getting out of sync."
                                );
                        }
                } else {
                        if ( !$this->mTrxLevel ) {
-                               wfWarn( "$fname: No transaction to commit, something got out of sync!" );
+                               wfWarn( "$fname: No transaction to commit, something got out of sync." );
                                return; // nothing to do
                        } elseif ( $this->mTrxAutomatic ) {
-                               throw new DBUnexpectedError(
-                                       $this,
-                                       "$fname: Explicit commit of implicit transaction."
-                               );
+                               // @TODO: make this an exception at some point
+                               wfLogDBError( "$fname: Explicit commit of implicit transaction." );
+                               return; // wait for the main transaction set commit round
                        }
                }
 
@@ -2796,14 +2780,19 @@ abstract class DatabaseBase implements IDatabase {
        }
 
        final public function rollback( $fname = __METHOD__, $flush = '' ) {
-               if ( $flush !== self::FLUSHING_INTERNAL && $flush !== self::FLUSHING_ALL_PEERS ) {
+               if ( $flush === self::FLUSHING_INTERNAL || $flush === self::FLUSHING_ALL_PEERS ) {
                        if ( !$this->mTrxLevel ) {
-                               wfWarn( "$fname: No transaction to rollback, something got out of sync!" );
                                return; // nothing to do
                        }
                } else {
                        if ( !$this->mTrxLevel ) {
+                               wfWarn( "$fname: No transaction to rollback, something got out of sync." );
                                return; // nothing to do
+                       } elseif ( $this->getFlag( DBO_TRX ) ) {
+                               throw new DBUnexpectedError(
+                                       $this,
+                                       "$fname: Expected mass rollback of all peer databases (DBO_TRX set)."
+                               );
                        }
                }
 
@@ -3297,13 +3286,29 @@ abstract class DatabaseBase implements IDatabase {
        }
 
        public function getScopedLockAndFlush( $lockKey, $fname, $timeout ) {
+               if ( $this->writesOrCallbacksPending() ) {
+                       // This only flushes transactions to clear snapshots, not to write data
+                       throw new DBUnexpectedError(
+                               $this,
+                               "$fname: Cannot COMMIT to clear snapshot because writes are pending."
+                       );
+               }
+
                if ( !$this->lock( $lockKey, $fname, $timeout ) ) {
                        return null;
                }
 
                $unlocker = new ScopedCallback( function () use ( $lockKey, $fname ) {
-                       $this->commit( __METHOD__, self::FLUSHING_INTERNAL );
-                       $this->unlock( $lockKey, $fname );
+                       if ( $this->trxLevel() ) {
+                               // There is a good chance an exception was thrown, causing any early return
+                               // from the caller. Let any error handler get a chance to issue rollback().
+                               // If there isn't one, let the error bubble up and trigger server-side rollback.
+                               $this->onTransactionResolution( function () use ( $lockKey, $fname ) {
+                                       $this->unlock( $lockKey, $fname );
+                               } );
+                       } else {
+                               $this->unlock( $lockKey, $fname );
+                       }
                } );
 
                $this->commit( __METHOD__, self::FLUSHING_INTERNAL );
index 867aeb8..1ecdd26 100644 (file)
@@ -149,7 +149,7 @@ class SavepointPostgres {
                $this->didbegin = false;
                /* If we are not in a transaction, we need to be for savepoint trickery */
                if ( !$dbw->trxLevel() ) {
-                       $dbw->begin( "FOR SAVEPOINT" );
+                       $dbw->begin( "FOR SAVEPOINT", DatabasePostgres::TRANSACTION_INTERNAL );
                        $this->didbegin = true;
                }
        }
@@ -1207,7 +1207,7 @@ __INDEXATTR__;
         * @param string $desiredSchema
         */
        function determineCoreSchema( $desiredSchema ) {
-               $this->begin( __METHOD__ );
+               $this->begin( __METHOD__, self::TRANSACTION_INTERNAL );
                if ( $this->schemaExists( $desiredSchema ) ) {
                        if ( in_array( $desiredSchema, $this->getSchemas() ) ) {
                                $this->mCoreSchema = $desiredSchema;
index af024b8..bdab09e 100644 (file)
@@ -40,6 +40,11 @@ interface IDatabase {
        /** @var int Callback triggered by rollback */
        const TRIGGER_ROLLBACK = 3;
 
+       /** @var string Transaction is requested by regular caller outside of the DB layer */
+       const TRANSACTION_EXPLICIT = '';
+       /** @var string Transaction is requested interally via DBO_TRX/startAtomic() */
+       const TRANSACTION_INTERNAL = 'implicit';
+
        /** @var string Transaction operation comes from service managing all DBs */
        const FLUSHING_ALL_PEERS = 'flush';
        /** @var string Transaction operation comes from the database class internally */
@@ -1342,6 +1347,7 @@ interface IDatabase {
         *
         * @param string $fname Caller name (usually __METHOD__)
         * @param callable $callback Callback that issues DB updates
+        * @return mixed $res Result of the callback (since 1.28)
         * @throws DBError
         * @throws RuntimeException
         * @throws UnexpectedValueException
@@ -1353,6 +1359,10 @@ interface IDatabase {
         * Begin a transaction. If a transaction is already in progress,
         * that transaction will be committed before the new transaction is started.
         *
+        * Only call this from code with outer transcation scope.
+        * See https://www.mediawiki.org/wiki/Database_transactions for details.
+        * Nesting of transactions is not supported.
+        *
         * Note that when the DBO_TRX flag is set (which is usually the case for web
         * requests, but not for maintenance scripts), any previous database query
         * will have started a transaction automatically.
@@ -1362,14 +1372,17 @@ interface IDatabase {
         * automatically because of the DBO_TRX flag.
         *
         * @param string $fname
+        * @param string $mode A situationally valid IDatabase::TRANSACTION_* constant [optional]
         * @throws DBError
         */
-       public function begin( $fname = __METHOD__ );
+       public function begin( $fname = __METHOD__, $mode = self::TRANSACTION_EXPLICIT );
 
        /**
         * Commits a transaction previously started using begin().
         * If no transaction is in progress, a warning is issued.
         *
+        * Only call this from code with outer transcation scope.
+        * See https://www.mediawiki.org/wiki/Database_transactions for details.
         * Nesting of transactions is not supported.
         *
         * @param string $fname
@@ -1390,7 +1403,11 @@ interface IDatabase {
         * Rollback a transaction previously started using begin().
         * If no transaction is in progress, a warning is issued.
         *
-        * No-op on non-transactional databases.
+        * Only call this from code with outer transcation scope.
+        * See https://www.mediawiki.org/wiki/Database_transactions for details.
+        * Nesting of transactions is not supported. If a serious unexpected error occurs,
+        * throwing an Exception is preferrable, using a pre-installed error handler to trigger
+        * rollback (in any case, failure to issue COMMIT will cause rollback server-side).
         *
         * @param string $fname
         * @param string $flush Flush flag, set to a situationally valid IDatabase::FLUSHING_*
@@ -1561,10 +1578,14 @@ interface IDatabase {
        /**
         * Acquire a named lock, flush any transaction, and return an RAII style unlocker object
         *
+        * Only call this from outer transcation scope and when only one DB will be affected.
+        * See https://www.mediawiki.org/wiki/Database_transactions for details.
+        *
         * This is suitiable for transactions that need to be serialized using cooperative locks,
         * where each transaction can see each others' changes. Any transaction is flushed to clear
         * out stale REPEATABLE-READ snapshot data. Once the returned object falls out of PHP scope,
-        * any transaction will be committed and the lock will be released.
+        * the lock will be released unless a transaction is active. If one is active, then the lock
+        * will be released when it either commits or rolls back.
         *
         * If the lock acquisition failed, then no transaction flush happens, and null is returned.
         *
index f39f54b..5d8f491 100644 (file)
@@ -257,7 +257,7 @@ class HTMLFormFieldCloner extends HTMLFormField {
         * @param array $values
         * @return string
         */
-       protected function getInputHTMLForKey( $key, $values ) {
+       protected function getInputHTMLForKey( $key, array $values ) {
                $displayFormat = isset( $this->mParams['format'] )
                        ? $this->mParams['format']
                        : $this->mParent->getDisplayFormat();
index 1e804c4..060cabb 100644 (file)
@@ -73,8 +73,12 @@ class AssembleUploadChunksJob extends Job {
                                return false;
                        }
 
+                       // We can only get warnings like 'duplicate' after concatenating the chunks
+                       $status = Status::newGood();
+                       $status->value = [ 'warnings' => $upload->checkWarnings() ];
+
                        // We have a new filekey for the fully concatenated file
-                       $newFileKey = $upload->getLocalFile()->getFileKey();
+                       $newFileKey = $upload->getStashFile()->getFileKey();
 
                        // Remove the old stash file row and first chunk file
                        $upload->stash->removeFileNoAuth( $this->params['filekey'] );
@@ -95,7 +99,7 @@ class AssembleUploadChunksJob extends Job {
                                        'stage' => 'assembling',
                                        'filekey' => $newFileKey,
                                        'imageinfo' => $imageInfo,
-                                       'status' => Status::newGood()
+                                       'status' => $status
                                ]
                        );
                } catch ( Exception $e ) {
index 408212a..3f66c06 100644 (file)
@@ -31,6 +31,10 @@ class EmptyBagOStuff extends BagOStuff {
                return false;
        }
 
+       public function add( $key, $value, $exp = 0 ) {
+               return true;
+       }
+
        public function set( $key, $value, $exp = 0, $flags = 0 ) {
                return true;
        }
index 143042c..c40c819 100644 (file)
@@ -33,6 +33,7 @@ use Psr\Log\NullLogger;
  * This class is intended for caching data from primary stores.
  * If the get() method does not return a value, then the caller
  * should query the new value and backfill the cache using set().
+ * The preferred way to do this logic is through getWithSetCallback().
  * When querying the store on cache miss, the closest DB replica
  * should be used. Try to avoid heavyweight DB master or quorum reads.
  * When the source data changes, a purge method should be called.
@@ -43,16 +44,23 @@ use Psr\Log\NullLogger;
  *
  * The simplest purge method is delete().
  *
- * Instances of this class must be configured to point to a valid
- * PubSub endpoint, and there must be listeners on the cache servers
- * that subscribe to the endpoint and update the caches.
+ * There are two supported ways to handle broadcasted operations:
+ *   - a) Configure the 'purge' EventRelayer to point to a valid PubSub endpoint
+ *        that has subscribed listeners on the cache servers applying the cache updates.
+ *   - b) Ignore the 'purge' EventRelayer configuration (default is NullEventRelayer)
+ *        and set up mcrouter as the underlying cache backend, using one of the memcached
+ *        BagOStuff classes as 'cache'. Use OperationSelectorRoute in the mcrouter settings
+ *        to configure 'set' and 'delete' operations to go to all DCs via AllAsyncRoute and
+ *        configure other operations to go to the local DC via PoolRoute (for reference,
+ *        see https://github.com/facebook/mcrouter/wiki/List-of-Route-Handles).
  *
- * Broadcasted operations like delete() and touchCheckKey() are done
- * synchronously in the local datacenter, but are relayed asynchronously.
- * This means that callers in other datacenters will see older values
- * for however many milliseconds the datacenters are apart. As with
- * any cache, this should not be relied on for cases where reads are
- * used to determine writes to source (e.g. non-cache) data stores.
+ * Broadcasted operations like delete() and touchCheckKey() are done asynchronously
+ * in all datacenters this way, though the local one should likely be near immediate.
+ *
+ * This means that callers in all datacenters may see older values for however many
+ * milliseconds that the purge took to reach that datacenter. As with any cache, this
+ * should not be relied on for cases where reads are used to determine writes to source
+ * (e.g. non-cache) data stores, except when reading immutable data.
  *
  * All values are wrapped in metadata arrays. Keys use a "WANCache:" prefix
  * to avoid collisions with keys that are not wrapped as metadata arrays. The
@@ -60,6 +68,7 @@ use Psr\Log\NullLogger;
  *   - a) "WANCache:v" : used for regular value keys
  *   - b) "WANCache:i" : used for temporarily storing values of tombstoned keys
  *   - c) "WANCache:t" : used for storing timestamp "check" keys
+ *   - d) "WANCache:m" : used for temporary mutex keys to avoid cache stampedes
  *
  * @ingroup Cache
  * @since 1.26
@@ -129,6 +138,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
        const VALUE_KEY_PREFIX = 'WANCache:v:';
        const INTERIM_KEY_PREFIX = 'WANCache:i:';
        const TIME_KEY_PREFIX = 'WANCache:t:';
+       const MUTEX_KEY_PREFIX = 'WANCache:m:';
 
        const PURGE_VAL_PREFIX = 'PURGED:';
 
@@ -456,8 +466,8 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
         *
         * When using potentially long-running ACID transactions, a good pattern is
         * to use a pre-commit hook to issue the delete. This means that immediately
-        * after commit, callers will see the tombstone in cache in the local datacenter
-        * and in the others upon relay. It also avoids the following race condition:
+        * after commit, callers will see the tombstone in cache upon purge relay.
+        * It also avoids the following race condition:
         *   - a) T1 begins, changes a row, and calls delete()
         *   - b) The HOLDOFF_TTL passes, expiring the delete() tombstone
         *   - c) T2 starts, reads the row and calls set() due to a cache miss
@@ -495,18 +505,11 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
                $key = self::VALUE_KEY_PREFIX . $key;
 
                if ( $ttl <= 0 ) {
-                       // Update the local datacenter immediately
-                       $ok = $this->cache->delete( $key );
                        // Publish the purge to all datacenters
-                       $ok = $this->relayDelete( $key ) && $ok;
+                       $ok = $this->relayDelete( $key );
                } else {
-                       // Update the local datacenter immediately
-                       $ok = $this->cache->set( $key,
-                               $this->makePurgeValue( microtime( true ), self::HOLDOFF_NONE ),
-                               $ttl
-                       );
                        // Publish the purge to all datacenters
-                       $ok = $this->relayPurge( $key, $ttl, self::HOLDOFF_NONE ) && $ok;
+                       $ok = $this->relayPurge( $key, $ttl, self::HOLDOFF_NONE );
                }
 
                return $ok;
@@ -559,8 +562,9 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
         * keys, the relevant "check" keys must be supplied for this to work.
         *
         * The "check" key essentially represents a last-modified field.
-        * When touched, keys using it via get(), getMulti(), or getWithSetCallback()
-        * will be invalidated. It is treated as being HOLDOFF_TTL seconds in the future
+        * When touched, the field will be updated on all cache servers.
+        * Keys using it via get(), getMulti(), or getWithSetCallback() will
+        * be invalidated. It is treated as being HOLDOFF_TTL seconds in the future
         * by those methods to avoid race conditions where dependent keys get updated
         * with stale values (e.g. from a DB slave).
         *
@@ -569,7 +573,8 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
         * When a few important keys get a large number of hits, a high cache
         * time is usually desired as well as "lockTSE" logic. The resetCheckKey()
         * method is less appropriate in such cases since the "time since expiry"
-        * cannot be inferred.
+        * cannot be inferred, causing any get() after the reset to treat the key
+        * as being "hot", resulting in more stale value usage.
         *
         * Note that "check" keys won't collide with other regular keys.
         *
@@ -582,14 +587,8 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
         * @return bool True if the item was purged or not found, false on failure
         */
        final public function touchCheckKey( $key, $holdoff = self::HOLDOFF_TTL ) {
-               $key = self::TIME_KEY_PREFIX . $key;
-               // Update the local datacenter immediately
-               $ok = $this->cache->set( $key,
-                       $this->makePurgeValue( microtime( true ), $holdoff ),
-                       self::CHECK_KEY_TTL
-               );
                // Publish the purge to all datacenters
-               return $this->relayPurge( $key, self::CHECK_KEY_TTL, $holdoff ) && $ok;
+               return $this->relayPurge( self::TIME_KEY_PREFIX . $key, self::CHECK_KEY_TTL, $holdoff );
        }
 
        /**
@@ -597,11 +596,14 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
         *
         * This is similar to touchCheckKey() in that keys using it via get(), getMulti(),
         * or getWithSetCallback() will be invalidated. The differences are:
-        *   - a) The timestamp will be deleted from all caches and lazily
+        *   - a) The "check" key will be deleted from all caches and lazily
         *        re-initialized when accessed (rather than set everywhere)
         *   - b) Thus, dependent keys will be known to be invalid, but not
         *        for how long (they are treated as "just" purged), which
         *        effects any lockTSE logic in getWithSetCallback()
+        *   - c) Since "check" keys are initialized only on the server the key hashes
+        *        to, any temporary ejection of that server will cause the value to be
+        *        seen as purged as a new server will initialize the "check" key.
         *
         * The advantage is that this does not place high TTL keys on every cache
         * server, making it better for code that will cache many different keys
@@ -620,11 +622,8 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
         * @return bool True if the item was purged or not found, false on failure
         */
        final public function resetCheckKey( $key ) {
-               $key = self::TIME_KEY_PREFIX . $key;
-               // Update the local datacenter immediately
-               $ok = $this->cache->delete( $key );
                // Publish the purge to all datacenters
-               return $this->relayDelete( $key ) && $ok;
+               return $this->relayDelete( self::TIME_KEY_PREFIX . $key );
        }
 
        /**
@@ -908,7 +907,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
                $lockAcquired = false;
                if ( $useMutex ) {
                        // Acquire a datacenter-local non-blocking lock
-                       if ( $this->cache->lock( $key, 0, self::LOCK_TTL ) ) {
+                       if ( $this->cache->add( self::MUTEX_KEY_PREFIX . $key, 1, self::LOCK_TTL ) ) {
                                // Lock acquired; this thread should update the key
                                $lockAcquired = true;
                        } elseif ( $value !== false && $this->isValid( $value, $versioned, $asOf, $minTime ) ) {
@@ -945,11 +944,15 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
                if ( ( $isTombstone && $lockTSE > 0 ) && $value !== false && $ttl >= 0 ) {
                        $tempTTL = max( 1, (int)$lockTSE ); // set() expects seconds
                        $wrapped = $this->wrap( $value, $tempTTL, $asOf );
-                       $this->cache->set( self::INTERIM_KEY_PREFIX . $key, $wrapped, $tempTTL );
-               }
-
-               if ( $lockAcquired ) {
-                       $this->cache->unlock( $key );
+                       // Avoid using set() to avoid pointless mcrouter broadcasting
+                       $this->cache->merge(
+                               self::INTERIM_KEY_PREFIX . $key,
+                               function () use ( $wrapped ) {
+                                       return $wrapped;
+                               },
+                               $tempTTL,
+                               1
+                       );
                }
 
                if ( $value !== false && $ttl >= 0 ) {
@@ -958,6 +961,11 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
                        $this->set( $key, $value, $ttl, $setOpts );
                }
 
+               if ( $lockAcquired ) {
+                       // Avoid using delete() to avoid pointless mcrouter broadcasting
+                       $this->cache->changeTTL( self::MUTEX_KEY_PREFIX . $key, 1 );
+               }
+
                return $value;
        }
 
@@ -1045,17 +1053,25 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
         * @return bool Success
         */
        protected function relayPurge( $key, $ttl, $holdoff ) {
-               $event = $this->cache->modifySimpleRelayEvent( [
-                       'cmd' => 'set',
-                       'key' => $key,
-                       'val' => 'PURGED:$UNIXTIME$:' . (int)$holdoff,
-                       'ttl' => max( $ttl, 1 ),
-                       'sbt' => true, // substitute $UNIXTIME$ with actual microtime
-               ] );
-
-               $ok = $this->purgeRelayer->notify( $this->purgeChannel, $event );
-               if ( !$ok ) {
-                       $this->lastRelayError = self::ERR_RELAY;
+               if ( $this->purgeRelayer instanceof EventRelayerNull ) {
+                       // This handles the mcrouter and the single-DC case
+                       $ok = $this->cache->set( $key,
+                               $this->makePurgeValue( microtime( true ), self::HOLDOFF_NONE ),
+                               $ttl
+                       );
+               } else {
+                       $event = $this->cache->modifySimpleRelayEvent( [
+                               'cmd' => 'set',
+                               'key' => $key,
+                               'val' => 'PURGED:$UNIXTIME$:' . (int)$holdoff,
+                               'ttl' => max( $ttl, 1 ),
+                               'sbt' => true, // substitute $UNIXTIME$ with actual microtime
+                       ] );
+
+                       $ok = $this->purgeRelayer->notify( $this->purgeChannel, $event );
+                       if ( !$ok ) {
+                               $this->lastRelayError = self::ERR_RELAY;
+                       }
                }
 
                return $ok;
@@ -1068,14 +1084,19 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
         * @return bool Success
         */
        protected function relayDelete( $key ) {
-               $event = $this->cache->modifySimpleRelayEvent( [
-                       'cmd' => 'delete',
-                       'key' => $key,
-               ] );
-
-               $ok = $this->purgeRelayer->notify( $this->purgeChannel, $event );
-               if ( !$ok ) {
-                       $this->lastRelayError = self::ERR_RELAY;
+               if ( $this->purgeRelayer instanceof EventRelayerNull ) {
+                       // This handles the mcrouter and the single-DC case
+                       $ok = $this->cache->delete( $key );
+               } else {
+                       $event = $this->cache->modifySimpleRelayEvent( [
+                               'cmd' => 'delete',
+                               'key' => $key,
+                       ] );
+
+                       $ok = $this->purgeRelayer->notify( $this->purgeChannel, $event );
+                       if ( !$ok ) {
+                               $this->lastRelayError = self::ERR_RELAY;
+                       }
                }
 
                return $ok;
index 3b3bdf7..43327c9 100644 (file)
@@ -393,6 +393,8 @@ class ResourceLoaderImageModule extends ResourceLoaderModule {
        public function getDefinitionSummary( ResourceLoaderContext $context ) {
                $this->loadFromDefinition();
                $summary = parent::getDefinitionSummary( $context );
+
+               $options = [];
                foreach ( [
                        'localBasePath',
                        'images',
@@ -401,29 +403,27 @@ class ResourceLoaderImageModule extends ResourceLoaderModule {
                        'selectorWithoutVariant',
                        'selectorWithVariant',
                ] as $member ) {
-                       $summary[$member] = $this->{$member};
+                       $options[$member] = $this->{$member};
                };
+
+               $summary[] = [
+                       'options' => $options,
+                       'fileHashes' => $this->getFileHashes( $context ),
+               ];
                return $summary;
        }
 
        /**
-        * Get the last modified timestamp of this module.
-        *
-        * @param ResourceLoaderContext $context Context in which to calculate
-        *     the modified time
-        * @return int UNIX timestamp
+        * Helper method for getDefinitionSummary.
         */
-       public function getModifiedTime( ResourceLoaderContext $context ) {
+       protected function getFileHashes( ResourceLoaderContext $context ) {
                $this->loadFromDefinition();
                $files = [];
                foreach ( $this->getImages( $context ) as $name => $image ) {
                        $files[] = $image->getPath( $context );
                }
-
                $files = array_values( array_unique( $files ) );
-               $filesMtime = max( array_map( [ __CLASS__, 'safeFilemtime' ], $files ) );
-
-               return $filesMtime;
+               return array_map( [ __CLASS__, 'safeFileHash' ], $files );
        }
 
        /**
@@ -455,4 +455,11 @@ class ResourceLoaderImageModule extends ResourceLoaderModule {
                $this->loadFromDefinition();
                return $this->position;
        }
+
+       /**
+        * @return string
+        */
+       public function getType() {
+               return self::LOAD_STYLES;
+       }
 }
diff --git a/includes/search/DummySearchIndexFieldDefinition.php b/includes/search/DummySearchIndexFieldDefinition.php
new file mode 100644 (file)
index 0000000..a2a6760
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+/**
+ * Dummy implementation of SearchIndexFieldDefinition for testing purposes.
+ *
+ * @since 1.28
+ */
+class DummySearchIndexFieldDefinition extends SearchIndexFieldDefinition {
+
+       /**
+        * @param SearchEngine $engine
+        *
+        * @return array
+        */
+       public function getMapping( SearchEngine $engine ) {
+               $mapping = [
+                       'name' => $this->name,
+                       'type' => $this->type,
+                       'flags' => $this->flags,
+                       'subfields' => []
+               ];
+
+               foreach ( $this->subfields as $subfield ) {
+                       $mapping['subfields'][] = $subfield->getMapping();
+               }
+
+               return $mapping;
+       }
+
+}
diff --git a/includes/search/ParserOutputSearchDataExtractor.php b/includes/search/ParserOutputSearchDataExtractor.php
new file mode 100644 (file)
index 0000000..df653f1
--- /dev/null
@@ -0,0 +1,92 @@
+<?php
+
+namespace MediaWiki\Search;
+
+use Category;
+use ParserOutput;
+use Title;
+
+/**
+ * Extracts data from ParserOutput for indexing in the search engine.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.28
+ */
+class ParserOutputSearchDataExtractor {
+
+       /**
+        * Get a list of categories, as an array with title text strings.
+        *
+        * @return string[]
+        */
+       public function getCategories( ParserOutput $parserOutput ) {
+               $categories = [];
+
+               foreach ( $parserOutput->getCategoryLinks() as $key ) {
+                       $categories[] = Category::newFromName( $key )->getTitle()->getText();
+               }
+
+               return $categories;
+       }
+
+       /**
+        * Get a list of external links from ParserOutput, as an array of strings.
+        *
+        * @return string[]
+        */
+       public function getExternalLinks( ParserOutput $parserOutput ) {
+               return array_keys( $parserOutput->getExternalLinks() );
+       }
+
+       /**
+        * Get a list of outgoing wiki links (including interwiki links), as
+        * an array of prefixed title strings.
+        *
+        * @return string[]
+        */
+       public function getOutgoingLinks( ParserOutput $parserOutput ) {
+               $outgoingLinks = [];
+
+               foreach ( $parserOutput->getLinks() as $linkedNamespace => $namespaceLinks ) {
+                       foreach ( array_keys( $namespaceLinks ) as $linkedDbKey ) {
+                               $outgoingLinks[] =
+                                       Title::makeTitle( $linkedNamespace, $linkedDbKey )->getPrefixedDBkey();
+                       }
+               }
+
+               return $outgoingLinks;
+       }
+
+       /**
+        * Get a list of templates used in the ParserOutput content, as prefixed title strings
+        *
+        * @return string[]
+        */
+       public function getTemplates( ParserOutput $parserOutput ) {
+               $templates = [];
+
+               foreach ( $parserOutput->getTemplates() as $tNS => $templatesInNS ) {
+                       foreach ( array_keys( $templatesInNS ) as $tDbKey ) {
+                               $templateTitle = Title::makeTitle( $tNS, $tDbKey );
+                               $templates[] = $templateTitle->getPrefixedText();
+                       }
+               }
+
+               return $templates;
+       }
+
+}
index 3a86c82..8a06b65 100644 (file)
@@ -2,8 +2,10 @@
 
 /**
  * Basic infrastructure of the field definition.
- * Specific engines will need to override it at least for getMapping,
- * but can reuse other parts.
+ *
+ * Specific engines should extend this class and at at least,
+ * override the getMapping method, but can reuse other parts.
+ *
  * @since 1.28
  */
 abstract class SearchIndexFieldDefinition implements SearchIndexField {
@@ -115,4 +117,12 @@ abstract class SearchIndexFieldDefinition implements SearchIndexField {
                $this->subfields = $subfields;
                return $this;
        }
+
+       /**
+        * @param SearchEngine $engine
+        *
+        * @return array
+        */
+       abstract public function getMapping( SearchEngine $engine );
+
 }
index 9690d45..26b86f9 100644 (file)
@@ -100,6 +100,25 @@ class SpecialSearch extends SpecialPage {
         * @param string $par
         */
        public function execute( $par ) {
+               $request = $this->getRequest();
+
+               // Fetch the search term
+               $search = str_replace( "\n", " ", $request->getText( 'search' ) );
+
+               // Historically search terms have been accepted not only in the search query
+               // parameter, but also as part of the primary url. This can have PII implications
+               // in releasing page view data. As such issue a 301 redirect to the correct
+               // URL.
+               if ( strlen( $par ) && !strlen( $search ) ) {
+                       $query = $request->getValues();
+                       unset( $query['title'] );
+                       // Strip underscores from title parameter; most of the time we'll want
+                       // text form here. But don't strip underscores from actual text params!
+                       $query['search'] = str_replace( '_', ' ', $par );
+                       $this->getOutput()->redirect( $this->getPageTitle()->getFullURL( $query ), 301 );
+                       return;
+               }
+
                $this->setHeaders();
                $this->outputHeader();
                $out = $this->getOutput();
@@ -110,15 +129,6 @@ class SpecialSearch extends SpecialPage {
                ] );
                $this->addHelpLink( 'Help:Searching' );
 
-               // Strip underscores from title parameter; most of the time we'll want
-               // text form here. But don't strip underscores from actual text params!
-               $titleParam = str_replace( '_', ' ', $par );
-
-               $request = $this->getRequest();
-
-               // Fetch the search term
-               $search = str_replace( "\n", " ", $request->getText( 'search', $titleParam ) );
-
                $this->load();
                if ( !is_null( $request->getVal( 'nsRemember' ) ) ) {
                        $this->saveNamespaces();
index ae16f70..e2f7763 100644 (file)
@@ -44,7 +44,7 @@ abstract class UploadBase {
        protected $mDesiredDestName, $mDestName, $mRemoveTempFile, $mSourceType;
        protected $mTitle = false, $mTitleError = 0;
        protected $mFilteredName, $mFinalExtension;
-       protected $mLocalFile, $mFileSize, $mFileProps;
+       protected $mLocalFile, $mStashFile, $mFileSize, $mFileProps;
        protected $mBlackListedExtensions;
        protected $mJavaDetected, $mSVGNSError;
 
@@ -912,7 +912,7 @@ abstract class UploadBase {
        /**
         * Return the local file and initializes if necessary.
         *
-        * @return LocalFile|UploadStashFile|null
+        * @return LocalFile|null
         */
        public function getLocalFile() {
                if ( is_null( $this->mLocalFile ) ) {
@@ -923,6 +923,13 @@ abstract class UploadBase {
                return $this->mLocalFile;
        }
 
+       /**
+        * @return UploadStashFile|null
+        */
+       public function getStashFile() {
+               return $this->mStashFile;
+       }
+
        /**
         * Like stashFile(), but respects extensions' wishes to prevent the stashing. verifyUpload() must
         * be called before calling this method (unless $isPartial is true).
@@ -997,7 +1004,7 @@ abstract class UploadBase {
        protected function doStashFile( User $user = null ) {
                $stash = RepoGroup::singleton()->getLocalRepo()->getUploadStash( $user );
                $file = $stash->stashFile( $this->mTempPath, $this->getSourceType() );
-               $this->mLocalFile = $file;
+               $this->mStashFile = $file;
 
                return $file;
        }
@@ -1975,18 +1982,16 @@ abstract class UploadBase {
         * @return array Image info
         */
        public function getImageInfo( $result ) {
-               $file = $this->getLocalFile();
-               /** @todo This cries out for refactoring.
-                *  We really want to say $file->getAllInfo(); here.
-                * Perhaps "info" methods should be moved into files, and the API should
-                * just wrap them in queries.
-                */
-               if ( $file instanceof UploadStashFile ) {
+               $localFile = $this->getLocalFile();
+               $stashFile = $this->getStashFile();
+               // Calling a different API module depending on whether the file was stashed is less than optimal.
+               // In fact, calling API modules here at all is less than optimal. Maybe it should be refactored.
+               if ( $stashFile ) {
                        $imParam = ApiQueryStashImageInfo::getPropertyNames();
-                       $info = ApiQueryStashImageInfo::getInfo( $file, array_flip( $imParam ), $result );
+                       $info = ApiQueryStashImageInfo::getInfo( $stashFile, array_flip( $imParam ), $result );
                } else {
                        $imParam = ApiQueryImageInfo::getPropertyNames();
-                       $info = ApiQueryImageInfo::getInfo( $file, array_flip( $imParam ), $result );
+                       $info = ApiQueryImageInfo::getInfo( $localFile, array_flip( $imParam ), $result );
                }
 
                return $info;
index 6368db8..9145a85 100644 (file)
@@ -76,18 +76,18 @@ class UploadFromChunks extends UploadFromFile {
 
                $this->verifyChunk();
                // Create a local stash target
-               $this->mLocalFile = parent::doStashFile( $user );
+               $this->mStashFile = parent::doStashFile( $user );
                // Update the initial file offset (based on file size)
-               $this->mOffset = $this->mLocalFile->getSize();
-               $this->mFileKey = $this->mLocalFile->getFileKey();
+               $this->mOffset = $this->mStashFile->getSize();
+               $this->mFileKey = $this->mStashFile->getFileKey();
 
                // Output a copy of this first to chunk 0 location:
-               $this->outputChunk( $this->mLocalFile->getPath() );
+               $this->outputChunk( $this->mStashFile->getPath() );
 
                // Update db table to reflect initial "chunk" state
                $this->updateChunkStatus();
 
-               return $this->mLocalFile;
+               return $this->mStashFile;
        }
 
        /**
@@ -158,7 +158,7 @@ class UploadFromChunks extends UploadFromFile {
                        return $status;
                }
 
-               // Update the mTempPath and mLocalFile
+               // Update the mTempPath and mStashFile
                // (for FileUpload or normal Stash to take over)
                $tStart = microtime( true );
                // This is a re-implementation of UploadBase::tryStashFile(), we can't call it because we
@@ -169,14 +169,14 @@ class UploadFromChunks extends UploadFromFile {
                        return $status;
                }
                try {
-                       $this->mLocalFile = parent::doStashFile( $this->user );
+                       $this->mStashFile = parent::doStashFile( $this->user );
                } catch ( UploadStashException $e ) {
                        $status->fatal( 'uploadstash-exception', get_class( $e ), $e->getMessage() );
                        return $status;
                }
 
                $tAmount = microtime( true ) - $tStart;
-               $this->mLocalFile->setLocalReference( $tmpFile ); // reuse (e.g. for getImageInfo())
+               $this->mStashFile->setLocalReference( $tmpFile ); // reuse (e.g. for getImageInfo())
                wfDebugLog( 'fileconcatenate', "Stashed combined file ($i chunks) in $tAmount seconds." );
 
                return $status;
index 50bcbc4..1fbdb7d 100644 (file)
@@ -143,24 +143,6 @@ class UploadFromStash extends UploadBase {
                return $this->mFileProps['sha1'];
        }
 
-       /*
-        * protected function verifyFile() inherited
-        */
-
-       /**
-        * Stash the file.
-        *
-        * @param User $user
-        * @return UploadStashFile
-        */
-       protected function doStashFile( User $user = null ) {
-               // replace mLocalFile with an instance of UploadStashFile, which adds some methods
-               // that are useful for stashed files.
-               $this->mLocalFile = parent::doStashFile( $user );
-
-               return $this->mLocalFile;
-       }
-
        /**
         * Remove a temporarily kept file stashed by saveTempUploadedFile().
         * @return bool Success
index d96710a..8b39d77 100644 (file)
  * @defgroup Language Language
  */
 
-if ( !defined( 'MEDIAWIKI' ) ) {
-       echo "This file is part of MediaWiki, it is not a valid entry point.\n";
-       exit( 1 );
-}
-
 use CLDRPluralRuleParser\Evaluator;
 
 /**
index 4921d09..aa81ac2 100644 (file)
        "right-changetags": "নির্দিষ্ট সংস্করণ এবং দীর্ঘ সম্পাদনাগুলোতে [[Special:Tags|ট্যাগ]] সংযোজন ও অপসারণ করুন",
        "right-deletechangetags": "ডাটাবেজ থেকে [[Special:Tags|ট্যাগ]] অপসারণ করা",
        "grant-group-email": "ইমেইল পাঠান",
+       "grant-group-private-information": "আপনার সম্পর্কিত ব্যক্তিগত তথ্যে প্রবেশাধিকার পায়",
        "grant-group-other": "বিবিধ কার্যকলাপ",
        "grant-createaccount": "অ্যাকাউন্ট তৈরি করুন",
        "grant-createeditmovepage": "পাতা তৈরি, সম্পাদনা এবং স্থানান্তর করুন",
        "grant-editmyoptions": "আপনার ব্যবহারকারী পছন্দসমূহ সম্পাদনা করুন",
        "grant-editmywatchlist": "আপনার নজরতালিকা সম্পাদনা করুন",
        "grant-editprotected": "সংরক্ষিত পাতা সম্পাদনা করুন",
+       "grant-privateinfo": "ব্যক্তিগত তথ্যে প্রবেশাধিকার",
        "grant-sendemail": "অন্য ব্যবহারকারীকে ইমেইল পাঠান",
        "grant-uploadfile": "নতুন ফাইল আপলোড করুন",
        "grant-basic": "মৌলিক অধিকার",
index 13cbfb7..f145ca9 100644 (file)
        "nolinkstoimage": "Aucune page n'utilise ce fichier.",
        "morelinkstoimage": "Voir [[Special:WhatLinksHere/$1|plus de liens]] vers ce fichier.",
        "linkstoimage-redirect": "$1 (redirection de fichier) $2",
-       "duplicatesoffile": "{{PLURAL:$1|Le fichier suivant est un duplicata|Les fichiers suivants sont des duplicatas}} de celui-ci ([[Special:FileDuplicateSearch/$2|plus de détails]]) :",
+       "duplicatesoffile": "{{PLURAL:$1|Le fichier suivant est un doublon|Les $1 fichiers suivants sont des doublons}} de celui-ci ([[Special:FileDuplicateSearch/$2|plus de détails]]) :",
        "sharedupload": "Ce fichier provient de : $1. Il peut être utilisé par d'autres projets.",
        "sharedupload-desc-there": "Ce fichier provient de : $1. Il peut être utilisé par d'autres projets.\nVeuillez consulter [$2 sa page de description] pour plus d'informations.",
        "sharedupload-desc-here": "Ce fichier provient de $1. Il peut être utilisé par d'autres projets.\nSa description sur sa [$2 page de description] est affichée ci-dessous.",
        "filedelete-intro-old": "Vous êtes en train d'effacer la version de '''[[Media:$1|$1]]''' du [$4 $2 à $3].",
        "filedelete-comment": "Motif :",
        "filedelete-submit": "Supprimer",
-       "filedelete-success": "'''$1''' a été supprimé.",
-       "filedelete-success-old": "La version de '''[[Media:$1|$1]]''' du $2 à $3 a été supprimée.",
-       "filedelete-nofile": "'''$1''' n'existe pas.",
-       "filedelete-nofile-old": "Il n'existe aucune version archivée de '''$1''' avec les attributs indiqués.",
+       "filedelete-success": "<strong>$1</strong> a été supprimé.",
+       "filedelete-success-old": "La version de <strong>[[Media:$1|$1]]</strong> du $2 à $3 a été supprimée.",
+       "filedelete-nofile": "<strong>$1</strong> n'existe pas.",
+       "filedelete-nofile-old": "Il n'existe aucune version archivée de <strong>$1</strong> avec les attributs indiqués.",
        "filedelete-otherreason": "Motif autre / supplémentaire :",
        "filedelete-reason-otherlist": "Autre motif",
        "filedelete-reason-dropdown": "* Motifs fréquents de suppression de fichiers\n** Violation du droit d'auteur\n** Fichier dupliqué",
-       "filedelete-edit-reasonlist": "Modifier les motifs fréquents de suppression",
-       "filedelete-maintenance": "La suppression et restauration de fichiers est temporairement désactivée durant la maintenance.",
+       "filedelete-edit-reasonlist": "Modifier les motifs de suppression",
+       "filedelete-maintenance": "La suppression et la restauration de fichiers sont  temporairement désactivées durant la maintenance.",
        "filedelete-maintenance-title": "Impossible de supprimer le fichier",
        "mimesearch": "Recherche par type de contenu MIME",
        "mimesearch-summary": "Cette page vous permet de filtrer les fichiers par leur type de contenu MIME.\nEntrée : type_de_contenu/sous-type ou type_de_contenu/*, par ex. <code>image/jpeg</code>.",
        "randompage-nopages": "Il n'y a aucune page dans {{PLURAL:$2|l'espace de noms|les espaces de noms}} : $1.",
        "randomincategory": "Page au hasard dans la catégorie",
        "randomincategory-invalidcategory": "« $1 » n’est pas un nom de catégorie valide.",
-       "randomincategory-nopages": "Il n’y a pas de page dans [[:Category:$1]].",
+       "randomincategory-nopages": "Il n’y a pas de pages dans la catégorie [[:Category:$1|$1]].",
        "randomincategory-category": "Catégorie :",
        "randomincategory-legend": "Page aléatoire dans la catégorie",
        "randomincategory-submit": "Lancer",
index 090585a..bda094a 100644 (file)
        "mainpage-nstab": "𐌰𐌽𐌰𐍃𐍄𐍉𐌳𐌴𐌹𐌽𐌹𐌻𐌰𐌿𐍆𐍃",
        "error": "𐌰𐌹𐍂𐌶𐌴𐌹",
        "databaseerror-error": "𐌰𐌹𐍂𐌶𐌴𐌹: $1",
-       "missing-article": "ð\90\8d\83ð\90\8c° ð\90\8c³ð\90\8c°ð\90\8d\84ð\90\8c°ð\90\8c±ð\90\8c¿ð\90\8d\83 ð\90\8c½ð\90\8c¹ ð\90\8c²ð\90\8c°ð\90\8c½ð\90\8c°ð\90\8c¼ ð\90\8c¸ð\90\8c°ð\90\8c½ð\90\8c° ð\90\8c±ð\90\8d\89ð\90\8cºð\90\8c°ð\90\8d\85ð\90\8c°ð\90\8c¿ð\90\8d\82ð\90\8c³ð\90\8c°ð\90\8c½ ð\90\8c´ð\90\8c¹ ð\90\8c¹ð\90\8d\84ð\90\8c° ð\90\8d\83ð\90\8cºð\90\8c°ð\90\8c» ð\90\8c±ð\90\8c¹ð\90\8c²ð\90\8c¹ð\90\8d\84ð\90\8c°ð\90\8c½: \"$1\" $2\n\n(The data base did not find the text of a page that it should have found, named \"$1\" $2.\n\nThis is usually caused by following an outdated diff or history link to a page that has been deleted.\n\nIf this is not the case, you may have found a bug in the software.\nPlease report this to an [[Special:ListUsers/sysop|administrator]], making note of the URL.)",
+       "missing-article": "ð\90\8c³ð\90\8c°ð\90\8d\84ð\90\8c°ð\90\8c±ð\90\8c´ð\90\8d\83 ð\90\8c½ð\90\8c¹ ð\90\8c±ð\90\8c¹ð\90\8c²ð\90\8c°ð\90\8d\84 ð\90\8c±ð\90\8d\89ð\90\8cºð\90\8d\89ð\90\8d\83 ð\90\8c»ð\90\8c°ð\90\8c¿ð\90\8c±ð\90\8c¹ð\90\8d\83 ð\90\8c¸ð\90\8c¹ð\90\8c¶ð\90\8c´ð\90\8c¹ ð\90\8d\83ð\90\8cºð\90\8c¿ð\90\8c»ð\90\8c³ð\90\8c´ð\90\8c³ð\90\8c¹ ð\90\8c±ð\90\8c¹ð\90\8c²ð\90\8c¹ð\90\8d\84ð\90\8c°ð\90\8c½, ð\90\8c·ð\90\8c°ð\90\8c¹ð\90\8d\84ð\90\8c°ð\90\8c½ð\90\8d\83 \"$1\" $2. \n\nð\90\8c¸ð\90\8c°ð\90\8d\84ð\90\8c° ð\90\8c¿ð\90\8d\86ð\90\8d\84ð\90\8c° ð\90\8d\85ð\90\8c°ð\90\8c¹ð\90\8d\82ð\90\8c¸ð\90\8c¹ð\90\8c¸ ð\90\8c¾ð\90\8c°ð\90\8c±ð\90\8c°ð\90\8c¹ ð\90\8c»ð\90\8c°ð\90\8c¹ð\90\8d\83ð\90\8d\84ð\90\8c¾ð\90\8c°ð\90\8c³ð\90\8c° ð\90\8d\86ð\90\8c°ð\90\8c¹ð\90\8d\82ð\90\8c½ð\90\8c¾ð\90\8c° ð\90\8c³ð\90\8c¹ð\90\8d\86ð\90\8d\86 ð\90\8c¸ð\90\8c°ð\90\8c¿ ð\90\8d\83ð\90\8d\80ð\90\8c¹ð\90\8c»ð\90\8c»ð\90\8c°ð\90\8c²ð\90\8c°ð\90\8d\85ð\90\8c¹ð\90\8d\83ð\90\8d\83 ð\90\8d\83ð\90\8c´ð\90\8c¹ ð\90\8d\86ð\90\8d\82ð\90\8c°ð\90\8cµð\90\8c¹ð\90\8d\83ð\90\8d\84ð\90\8c¹ð\90\8c³ð\90\8c° ð\90\8c¹ð\90\8d\83ð\90\8d\84. ð\90\8c½ð\90\8c¹ð\90\8c±ð\90\8c°ð\90\8c¹ ð\90\8c¹ð\90\8d\83ð\90\8d\84, ð\90\8c¼ð\90\8c°ð\90\8c·ð\90\8d\84ð\90\8d\83 ð\90\8c¹ð\90\8d\83ð\90\8d\84 ð\90\8c´ð\90\8c¹ ð\90\8c±ð\90\8c¹ð\90\8c²ð\90\8c´ð\90\8d\84ð\90\8c´ð\90\8c¹ð\90\8d\83 ð\90\8c°ð\90\8c¹ð\90\8d\82ð\90\8c¶ð\90\8c´ð\90\8c¹ð\90\8c½ ð\90\8c¹ð\90\8c½ ð\90\8d\83ð\90\8c°ð\90\8c¿ð\90\8d\86ð\90\8d\84ð\90\8d\85ð\90\8c°ð\90\8c¹ð\90\8d\82ð\90\8c°. \n\nð\90\8c±ð\90\8c¹ð\90\8c³ð\90\8c¾ð\90\8c°ð\90\8c¼ ð\90\8c¸ð\90\8c¿ð\90\8cº, ð\90\8c¼ð\90\8c´ð\90\8d\82ð\90\8c´ð\90\8c¹ ð\90\8c¸ð\90\8c°ð\90\8d\84ð\90\8c° ð\90\8c³ð\90\8c¿ [[Special:ListUsers/sysop\n|ð\90\8d\82ð\90\8c´ð\90\8c¹ð\90\8cº]] ð\90\8c²ð\90\8c¹ð\90\8d\86ð\90\8c¿ð\90\8c· ð\90\8c²ð\90\8c°ð\90\8d\85ð\90\8c¹ð\90\8d\83ð\90\8d\83.",
        "badtitle": "𐌿𐌽𐍂𐌰𐌹𐌷𐍄𐌰𐍄𐌰 𐌿𐍆𐌰𐍂𐌼𐌴𐌻𐌹",
        "badtitletext": "𐍆𐍂𐌰𐌹𐌷𐌰𐌽𐍃 𐌻𐌰𐌿𐍆𐍃 𐍅𐌰𐍃 𐌿𐌽𐌲𐌰𐌼𐌰𐌲𐌰𐌽𐌳𐍃, 𐌻𐌰𐌿𐍃, 𐌰𐌹𐌸𐌸𐌰𐌿 𐌿𐌽𐍂𐌰𐌹𐌷𐍄𐌰𐌱𐌰 𐌲𐌰𐍅𐌹𐌳𐌰𐌽𐍃 𐌼𐌹𐌸𐍂𐌰𐌶𐌳𐌰 𐌸𐌰𐌿 𐌼𐌹𐌸-𐍅𐌹𐌺𐌹 𐌿𐍆𐌰𐍂𐌼𐌴𐌻𐌹. 𐌼𐌰𐌲𐌹 𐌷𐌰𐌱𐌰𐌽 𐌰𐌹𐌽𐌰 𐌸𐌰𐌿 𐌼𐌰𐌽𐌰𐌲𐌹𐌶𐍉𐍃 𐌱𐍉𐌺𐍉𐍃 𐌱𐍂𐌿𐌺𐌹𐌳𐍉𐍃 𐌹𐌽 𐌿𐍆𐌰𐍂𐌼𐌴𐌻𐌾𐌰𐌼.",
        "viewsource": "𐍃𐌰𐌹𐍈 𐌱𐍂𐌿𐌽𐌽𐌰𐌽",
index c756f47..b13f5f9 100644 (file)
        "action-applychangetags": "appreca tag pe' tramente ca se fanno 'e cagnamiente vuoste",
        "action-changetags": "azzecca o lèva tag a caso dint'a verziune nnividuale e riggistre 'e log",
        "action-deletechangetags": "scancellare 'e tag d' 'o database",
+       "action-purge": "agghiuorna sta paggena",
        "nchanges": "$1 {{PLURAL:$1|cagnamiento|cagnamiente}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|'a ll'urdema visita}}",
        "enhancedrc-history": "cronologgia",
        "uploadstash-errclear": "'A pulezzia d' 'e file scassaje.",
        "uploadstash-refresh": "Agghiuorna l'elenco d' 'e file",
        "uploadstash-thumbnail": "vide miniatura",
+       "uploadstash-exception": "Nun s'è pututo sarvà 'a càrreca dint' 'a stash ($1): \"$2\".",
        "invalid-chunk-offset": "Distanza d' 'a parte nun valida",
        "img-auth-accessdenied": "Acciesso negato",
        "img-auth-nopathinfo": "PATH_INFO mancante.\n'O server nun è mpustato pe' passà sta nfurmazione.\nPuò darse ca, essenno basato ncopp'a CGI, nun putesse suppurtà img_auth.\nVide https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Image_Authorization",
        "watchnologin": "Acciesso nun affettuato",
        "addwatch": "Miette dint' 'a l'elenco 'e paggene cuntrullate",
        "addedwatchtext": "'A paggena \"[[:$1]]\" e 'a paggena 'e chiacchiera è stata azzeccata dint'a l'elenco 'e [[Special:Watchlist|paggene cuntrullate]].",
+       "addedwatchtext-talk": "\"[[:$1]]\" e 'a paggena 'e chiacchiera suòccia è stata azzeccata dint'a l'elenco 'e [[Special:Watchlist|paggene cuntrullate]] vuosto.",
        "addedwatchtext-short": "Chista paggena \"$1\" è stata azzeccata a l'elenco 'e paggene cuntrullate.",
        "removewatch": "Leva 'a l'elenco 'e paggene cuntrullate",
-       "removedwatchtext": "\"[[:$1]]\" 'e 'a paggena 'e chiacchiera soja so' state scancellata 'a l'elenco [[Special:Watchlist|'e paggene cuntrullate]] vuosto.",
+       "removedwatchtext": "\"[[:$1]]\" e 'a paggena 'e chiacchiera soja so' state scancellate 'a l'elenco [[Special:Watchlist|'e paggene cuntrullate]] vuosto.",
+       "removedwatchtext-talk": "\"[[:$1]]\" e 'a paggena 'e chiacchiera soja so' state luvate 'a l'elenco [[Special:Watchlist|'e paggene cuntrullate]] vuosto.",
        "removedwatchtext-short": "Chista paggena \"$1\" è stata luvata a l'elenco 'e paggene cuntrullate.",
        "watch": "Secuta",
        "watchthispage": "Tiene d'uocchio sta paggena",
index b31fe82..c3a287d 100644 (file)
@@ -68,6 +68,9 @@ return call_user_func( function () {
                        'es5-shim',
                        'oojs',
                        'oojs-ui-core.styles',
+                       'oojs-ui.styles.icons',
+                       'oojs-ui.styles.indicators',
+                       'oojs-ui.styles.textures',
                        'mediawiki.language',
                ],
                'targets' => [ 'desktop', 'mobile' ],
@@ -78,14 +81,6 @@ return call_user_func( function () {
                'styles' => 'resources/src/oojs-ui-local.css', // HACK, see inside the file
                'skinStyles' => $getSkinSpecific( 'core' ),
                'targets' => [ 'desktop', 'mobile' ],
-               // ResourceLoaderImageModule doesn't support 'skipFunction', so instead we set this up so that
-               // this module is skipped together with its dependencies. Nothing else depends on these modules.
-               'dependencies' => [
-                       'oojs-ui.styles.icons',
-                       'oojs-ui.styles.indicators',
-                       'oojs-ui.styles.textures',
-               ],
-               'skipFunction' => 'resources/src/oojs-ui-styles-skip.js',
        ];
 
        // Additional widgets and layouts module.
index 0f5cf51..dae2549 100644 (file)
@@ -1,4 +1,4 @@
 These license icons are used in LocalSettings.php files that are generated by
-the installer. Although public domain has been removed from the installer as
-an option, the image needs to remain here to support installations which refer
-to it in LocalSettings.php.
+the installer. Although "Public domain" has been removed from the installer as
+an option, the public-domain.png image needs to remain here to support older
+installations that refer to it in LocalSettings.php.
diff --git a/resources/src/oojs-ui-styles-skip.js b/resources/src/oojs-ui-styles-skip.js
deleted file mode 100644 (file)
index 57c905a..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-/*!
- * Skip function for OOjs UI PHP style modules.
- *
- * The `<meta name="X-OOUI-PHP" />` is added to pages by OutputPage::enableOOUI().
- *
- * Looking for elements in the DOM might be expensive, but it's probably better than double-loading
- * 200 KB of CSS with embedded images because of bug T87871.
- */
-return !!jQuery( 'meta[name="X-OOUI-PHP"]' ).length;
index a7df221..7d2ba8d 100644 (file)
@@ -172,6 +172,7 @@ class AuthenticationRequestTest extends \MediaWikiTestCase {
                                'type' => 'string',
                                'label' => $msg,
                                'help' => $msg,
+                               'sensitive' => true,
                        ],
                        'string3' => [
                                'type' => 'string',
@@ -206,6 +207,7 @@ class AuthenticationRequestTest extends \MediaWikiTestCase {
                $expect = $req1->getFieldInfo();
                foreach ( $expect as $name => &$options ) {
                        $options['optional'] = !empty( $options['optional'] );
+                       $options['sensitive'] = !empty( $options['sensitive'] );
                }
                unset( $options );
                $this->assertEquals( $expect, $fields );
@@ -225,8 +227,10 @@ class AuthenticationRequestTest extends \MediaWikiTestCase {
 
                $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] );
                $expect += $req2->getFieldInfo();
+               $expect['string1']['sensitive'] = true;
                $expect['string2']['optional'] = false;
                $expect['string3']['optional'] = false;
+               $expect['string3']['sensitive'] = false;
                $expect['select']['options']['bar'] = $msg;
                $this->assertEquals( $expect, $fields );
 
@@ -237,6 +241,7 @@ class AuthenticationRequestTest extends \MediaWikiTestCase {
                $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] );
                $expect += $req2->getFieldInfo();
                $expect['string1']['optional'] = false;
+               $expect['string1']['sensitive'] = true;
                $expect['string3']['optional'] = false;
                $expect['select']['optional'] = false;
                $expect['select']['options']['bar'] = $msg;
@@ -246,7 +251,11 @@ class AuthenticationRequestTest extends \MediaWikiTestCase {
 
                $fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] );
                $expect = $req1->getFieldInfo() + $req2->getFieldInfo();
+               foreach ( $expect as $name => &$options ) {
+                       $options['sensitive'] = !empty( $options['sensitive'] );
+               }
                $expect['string1']['optional'] = false;
+               $expect['string1']['sensitive'] = true;
                $expect['string2']['optional'] = true;
                $expect['string3']['optional'] = true;
                $expect['select']['optional'] = false;
index aa0e3c7..b5c8a36 100644 (file)
@@ -32,6 +32,13 @@ abstract class AuthenticationRequestTestCase extends \MediaWikiTestCase {
                        if ( isset( $data['image'] ) ) {
                                $this->assertType( 'string', $data['image'], "Field $field, image" );
                        }
+                       if ( isset( $data['sensitive'] ) ) {
+                               $this->assertType( 'bool', $data['sensitive'], "Field $field, sensitive" );
+                       }
+                       if ( $data['type'] === 'password' ) {
+                               $this->assertTrue( !empty( $data['sensitive'] ),
+                                       "Field $field, password field must be sensitive" );
+                       }
 
                        switch ( $data['type'] ) {
                                case 'string':
index 18c46f7..c52c3e9 100644 (file)
@@ -60,19 +60,25 @@ class EmailNotificationSecondaryAuthenticationProviderTest extends \PHPUnit_Fram
                $creator = $this->getMock( 'User' );
                $userWithoutEmail = $this->getMock( 'User' );
                $userWithoutEmail->expects( $this->any() )->method( 'getEmail' )->willReturn( '' );
+               $userWithoutEmail->expects( $this->any() )->method( 'getInstanceForUpdate' )->willReturnSelf();
                $userWithoutEmail->expects( $this->never() )->method( 'sendConfirmationMail' );
                $userWithEmailError = $this->getMock( 'User' );
                $userWithEmailError->expects( $this->any() )->method( 'getEmail' )->willReturn( 'foo@bar.baz' );
+               $userWithEmailError->expects( $this->any() )->method( 'getInstanceForUpdate' )->willReturnSelf();
                $userWithEmailError->expects( $this->any() )->method( 'sendConfirmationMail' )
                        ->willReturn( \Status::newFatal( 'fail' ) );
                $userExpectsConfirmation = $this->getMock( 'User' );
                $userExpectsConfirmation->expects( $this->any() )->method( 'getEmail' )
                        ->willReturn( 'foo@bar.baz' );
+               $userExpectsConfirmation->expects( $this->any() )->method( 'getInstanceForUpdate' )
+                       ->willReturnSelf();
                $userExpectsConfirmation->expects( $this->once() )->method( 'sendConfirmationMail' )
                        ->willReturn( \Status::newGood() );
                $userNotExpectsConfirmation = $this->getMock( 'User' );
                $userNotExpectsConfirmation->expects( $this->any() )->method( 'getEmail' )
                        ->willReturn( 'foo@bar.baz' );
+               $userNotExpectsConfirmation->expects( $this->any() )->method( 'getInstanceForUpdate' )
+                       ->willReturnSelf();
                $userNotExpectsConfirmation->expects( $this->never() )->method( 'sendConfirmationMail' );
 
                $provider = new EmailNotificationSecondaryAuthenticationProvider( [
index bb9050f..39948ca 100644 (file)
@@ -376,8 +376,7 @@ class ContentHandlerTest extends MediaWikiTestCase {
                $content = new WikitextContent( 'test text' );
                $ok = ContentHandler::runLegacyHooks(
                        'testRunLegacyHooks',
-                       [ 'foo', &$content, 'bar' ],
-                       false
+                       [ 'foo', &$content, 'bar' ]
                );
 
                $this->assertTrue( $ok, "runLegacyHooks should have returned true" );
@@ -415,6 +414,32 @@ class ContentHandlerTest extends MediaWikiTestCase {
                $this->assertInstanceOf( $handlerClass, $handler );
        }
 
+       public function testGetFieldsForSearchIndex() {
+               $searchEngine = $this->newSearchEngine();
+
+               $handler = ContentHandler::getForModelID( CONTENT_MODEL_WIKITEXT );
+
+               $fields = $handler->getFieldsForSearchIndex( $searchEngine );
+
+               $this->assertArrayHasKey( 'category', $fields );
+               $this->assertArrayHasKey( 'external_link', $fields );
+               $this->assertArrayHasKey( 'outgoing_link', $fields );
+               $this->assertArrayHasKey( 'template', $fields );
+       }
+
+       private function newSearchEngine() {
+               $searchEngine = $this->getMockBuilder( 'SearchEngine' )
+                       ->getMock();
+
+               $searchEngine->expects( $this->any() )
+                       ->method( 'makeSearchFieldMapping' )
+                       ->will( $this->returnCallback( function( $name, $type ) {
+                                       return new DummySearchIndexFieldDefinition( $name, $type );
+                       } ) );
+
+               return $searchEngine;
+       }
+
        /**
         * @covers ContentHandler::getDataForSearchIndex
         */
@@ -425,7 +450,7 @@ class ContentHandlerTest extends MediaWikiTestCase {
 
                $this->setTemporaryHook( 'SearchDataForIndex',
                        function ( &$fields, ContentHandler $handler, WikiPage $page, ParserOutput $output,
-                                  SearchEngine $engine ) {
+                                          SearchEngine $engine ) {
                                $fields['testDataField'] = 'test content';
                        } );
 
index 6d83057..4301fb8 100644 (file)
@@ -25,61 +25,6 @@ class WikitextStructureTest extends MediaWikiLangTestCase {
                return new WikiTextStructure( $this->getParserOutput( $text ) );
        }
 
-       public function testCategories() {
-               $text = <<<END
-We also have a {{Template}} and an {{Another template}} in addition. 
-This text also has [[Category:Some Category| ]] and then [[Category:Yet another category]].
-And [[Category:Some Category| this category]] is repeated.
-END;
-               $struct = $this->getStructure( $text );
-               $cats = $struct->categories();
-               $this->assertCount( 2, $cats );
-               $this->assertContains( "Some Category", $cats );
-               $this->assertContains( "Yet another category", $cats );
-       }
-
-       public function testOutgoingLinks() {
-               $text = <<<END
-Here I add link to [[Some Page]]. And [[Some Page|This same page]] gets linked twice. 
-We also have [[File:Image.jpg|image]].
-We also have a {{Template}} and an {{Another template}} in addition. 
-Some templates are {{lowercase}}.
-And [[Some_Page]] is linked again. 
-It also has [[Category:Some Category| ]] and then [[Category:Yet another category]].
-Also link to a [[Talk:TestTitle|talk page]] is here. 
-END;
-               $struct = $this->getStructure( $text );
-               $links = $struct->outgoingLinks();
-               $this->assertContains( "Some_Page", $links );
-               $this->assertContains( "Template:Template", $links );
-               $this->assertContains( "Template:Another_template", $links );
-               $this->assertContains( "Template:Lowercase", $links );
-               $this->assertContains( "Talk:TestTitle", $links );
-               $this->assertCount( 5, $links );
-       }
-
-       public function testTemplates() {
-               $text = <<<END
-We have a {{Template}} and an {{Another template}} in addition. 
-Some templates are {{lowercase}}. And this {{Template}} is repeated. 
-Here is {{another_template|with=argument}}.
-This is a template that {{Xdoes not exist}}.
-END;
-               $this->setTemporaryHook( 'TitleExists', function ( Title $title, &$exists ) {
-                       $txt = $title->getBaseText();
-                       if ( $txt[0] != 'X' ) {
-                               $exists = true;
-                       }
-                       return true;
-               } );
-               $struct = $this->getStructure( $text );
-               $templates = $struct->templates();
-               $this->assertCount( 3, $templates );
-               $this->assertContains( "Template:Template", $templates );
-               $this->assertContains( "Template:Another template", $templates );
-               $this->assertContains( "Template:Lowercase", $templates );
-       }
-
        public function testHeadings() {
                $text = <<<END
 Some text here
index 723f1c3..7e55a73 100644 (file)
@@ -285,8 +285,42 @@ class DatabaseTest extends MediaWikiTestCase {
                        $called = true;
                        $db->setFlag( DBO_TRX );
                } );
-               $db->rollback( __METHOD__ );
+               $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS );
                $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' );
                $this->assertTrue( $called, 'Callback reached' );
        }
+
+       public function testGetScopedLock() {
+               $db = $this->db;
+
+               $db->setFlag( DBO_TRX );
+               try {
+                       $this->badLockingMethodImplicit( $db );
+               } catch ( RunTimeException $e ) {
+                       $this->assertTrue( $db->trxLevel() > 0, "Transaction not committed." );
+               }
+               $db->clearFlag( DBO_TRX );
+               $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS );
+               $this->assertTrue( $db->lockIsFree( 'meow', __METHOD__ ) );
+
+               try {
+                       $this->badLockingMethodExplicit( $db );
+               } catch ( RunTimeException $e ) {
+                       $this->assertTrue( $db->trxLevel() > 0, "Transaction not committed." );
+               }
+               $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS );
+               $this->assertTrue( $db->lockIsFree( 'meow', __METHOD__ ) );
+       }
+
+       private function badLockingMethodImplicit( IDatabase $db ) {
+               $lock = $db->getScopedLockAndFlush( 'meow', __METHOD__, 1 );
+               $db->query( "SELECT 1" ); // trigger DBO_TRX
+               throw new RunTimeException( "Uh oh!" );
+       }
+
+       private function badLockingMethodExplicit( IDatabase $db ) {
+               $lock = $db->getScopedLockAndFlush( 'meow', __METHOD__, 1 );
+               $db->begin( __METHOD__ );
+               throw new RunTimeException( "Uh oh!" );
+       }
 }
index a4871a6..f8dda6f 100644 (file)
@@ -44,6 +44,7 @@ class ObjectFactoryTest extends PHPUnit_Framework_TestCase {
 
        /**
         * @covers ObjectFactory::getObjectFromSpec
+        * @covers ObjectFactory::expandClosures
         */
        public function testClosureExpansionEnabled() {
                $obj = ObjectFactory::getObjectFromSpec( [
@@ -80,6 +81,44 @@ class ObjectFactoryTest extends PHPUnit_Framework_TestCase {
                $this->assertSame( 'unwrapped', $obj->setterArgs[0] );
        }
 
+       /**
+        * @covers ObjectFactory::getObjectFromSpec
+        */
+       public function testGetObjectFromFactory() {
+               $args = [ 'a', 'b' ];
+               $obj = ObjectFactory::getObjectFromSpec( [
+                       'factory' => function ( $a, $b ) {
+                               return new ObjectFactoryTestFixture( $a, $b );
+                       },
+                       'args' => $args,
+               ] );
+               $this->assertSame( $args, $obj->args );
+       }
+
+       /**
+        * @covers ObjectFactory::getObjectFromSpec
+        * @expectedException InvalidArgumentException
+        */
+       public function testGetObjectFromInvalid() {
+               $args = [ 'a', 'b' ];
+               $obj = ObjectFactory::getObjectFromSpec( [
+                       // Missing 'class' or 'factory'
+                       'args' => $args,
+               ] );
+       }
+
+       /**
+        * @covers ObjectFactory::getObjectFromSpec
+        * @dataProvider provideConstructClassInstance
+        */
+       public function testGetObjectFromClass( $args ) {
+               $obj = ObjectFactory::getObjectFromSpec( [
+                       'class' => 'ObjectFactoryTestFixture',
+                       'args' => $args,
+               ] );
+               $this->assertSame( $args, $obj->args );
+       }
+
        /**
         * @covers ObjectFactory::constructClassInstance
         * @dataProvider provideConstructClassInstance
@@ -91,7 +130,7 @@ class ObjectFactoryTest extends PHPUnit_Framework_TestCase {
                $this->assertSame( $args, $obj->args );
        }
 
-       public function provideConstructClassInstance() {
+       public static function provideConstructClassInstance() {
                // These args go to 11. I thought about making 10 one louder, but 11!
                return [
                        '0 args' => [ [] ],
@@ -110,6 +149,7 @@ class ObjectFactoryTest extends PHPUnit_Framework_TestCase {
        }
 
        /**
+        * @covers ObjectFactory::constructClassInstance
         * @expectedException InvalidArgumentException
         */
        public function testNamedArgs() {
index 6a3cd15..35005f5 100644 (file)
@@ -206,8 +206,10 @@ class WANObjectCacheTest extends MediaWikiTestCase {
                $value = wfRandomString();
 
                $calls = 0;
-               $func = function() use ( &$calls, $value ) {
+               $func = function() use ( &$calls, $value, $cache, $key ) {
                        ++$calls;
+                       // Immediately kill any mutex rather than waiting a second
+                       $cache->delete( $cache::MUTEX_KEY_PREFIX . $key );
                        return $value;
                };
 
@@ -216,7 +218,7 @@ class WANObjectCacheTest extends MediaWikiTestCase {
                $this->assertEquals( 1, $calls, 'Value was populated' );
 
                // Acquire a lock to verify that getWithSetCallback uses lockTSE properly
-               $this->internalCache->lock( $key, 0 );
+               $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
 
                $checkKeys = [ wfRandomString() ]; // new check keys => force misses
                $ret = $cache->getWithSetCallback( $key, 30, $func,
@@ -246,9 +248,11 @@ class WANObjectCacheTest extends MediaWikiTestCase {
                $value = wfRandomString();
 
                $calls = 0;
-               $func = function( $oldValue, &$ttl, &$setOpts ) use ( &$calls, $value ) {
+               $func = function( $oldValue, &$ttl, &$setOpts ) use ( &$calls, $value, $cache, $key ) {
                        ++$calls;
                        $setOpts['since'] = microtime( true ) - 10;
+                       // Immediately kill any mutex rather than waiting a second
+                       $cache->delete( $cache::MUTEX_KEY_PREFIX . $key );
                        return $value;
                };
 
@@ -261,7 +265,7 @@ class WANObjectCacheTest extends MediaWikiTestCase {
                $this->assertEquals( 1, $calls, 'Value was generated' );
 
                // Acquire a lock to verify that getWithSetCallback uses lockTSE properly
-               $this->internalCache->lock( $key, 0 );
+               $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
                $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] );
                $this->assertEquals( $value, $ret );
                $this->assertEquals( 1, $calls, 'Callback was not used' );
@@ -278,8 +282,10 @@ class WANObjectCacheTest extends MediaWikiTestCase {
                $busyValue = wfRandomString();
 
                $calls = 0;
-               $func = function() use ( &$calls, $value ) {
+               $func = function() use ( &$calls, $value, $cache, $key ) {
                        ++$calls;
+                       // Immediately kill any mutex rather than waiting a second
+                       $cache->delete( $cache::MUTEX_KEY_PREFIX . $key );
                        return $value;
                };
 
@@ -288,7 +294,7 @@ class WANObjectCacheTest extends MediaWikiTestCase {
                $this->assertEquals( 1, $calls, 'Value was populated' );
 
                // Acquire a lock to verify that getWithSetCallback uses busyValue properly
-               $this->internalCache->lock( $key, 0 );
+               $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
 
                $checkKeys = [ wfRandomString() ]; // new check keys => force misses
                $ret = $cache->getWithSetCallback( $key, 30, $func,
@@ -307,13 +313,13 @@ class WANObjectCacheTest extends MediaWikiTestCase {
                $this->assertEquals( $busyValue, $ret, 'Callback was not used; used busy value' );
                $this->assertEquals( 2, $calls, 'Callback was not used; used busy value' );
 
-               $this->internalCache->unlock( $key );
+               $this->internalCache->delete( $cache::MUTEX_KEY_PREFIX . $key );
                $ret = $cache->getWithSetCallback( $key, 30, $func,
                        [ 'lockTSE' => 30, 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] );
                $this->assertEquals( $value, $ret, 'Callback was used; saved interim' );
                $this->assertEquals( 3, $calls, 'Callback was used; saved interim' );
 
-               $this->internalCache->lock( $key, 0 );
+               $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
                $ret = $cache->getWithSetCallback( $key, 30, $func,
                        [ 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] );
                $this->assertEquals( $value, $ret, 'Callback was not used; used interim' );
@@ -694,4 +700,26 @@ class WANObjectCacheTest extends MediaWikiTestCase {
                $this->cache->set( $key, $value, 30, $opts );
                $this->assertEquals( false, $this->cache->get( $key ), "Pending value not written." );
        }
+
+       public function testMcRouterSupport() {
+               $localBag = $this->getMock( 'EmptyBagOStuff', [ 'set', 'delete' ] );
+               $localBag->expects( $this->never() )->method( 'set' );
+               $localBag->expects( $this->never() )->method( 'delete' );
+               $wanCache = new WANObjectCache( [
+                       'cache' => $localBag,
+                       'pool' => 'testcache-hash',
+                       'relayer' => new EventRelayerNull( [] )
+               ] );
+               $valFunc = function () {
+                       return 1;
+               };
+
+               // None of these should use broadcasting commands (e.g. SET, DELETE)
+               $wanCache->get( 'x' );
+               $wanCache->get( 'x', $ctl, [ 'check1' ] );
+               $wanCache->getMulti( [ 'x', 'y' ] );
+               $wanCache->getMulti( [ 'x', 'y' ], $ctls, [ 'check2' ] );
+               $wanCache->getWithSetCallback( 'p', 30, $valFunc );
+               $wanCache->getCheckKeyTime( 'zzz' );
+       }
 }
diff --git a/tests/phpunit/includes/search/ParserOutputSearchDataExtractorTest.php b/tests/phpunit/includes/search/ParserOutputSearchDataExtractorTest.php
new file mode 100644 (file)
index 0000000..69d0b76
--- /dev/null
@@ -0,0 +1,70 @@
+<?php
+
+use MediaWiki\Search\ParserOutputSearchDataExtractor;
+
+/**
+ * @group Search
+ * @covers MediaWiki\Search\ParserOutputSearchDataExtractor
+ */
+class ParserOutputSearchDataExtractorTest extends MediaWikiLangTestCase {
+
+       public function testGetCategories() {
+               $categories = [
+                       'Foo_bar' => 'Bar',
+                       'New_page' => ''
+               ];
+
+               $parserOutput = new ParserOutput( '', [], $categories );
+
+               $searchDataExtractor = new ParserOutputSearchDataExtractor();
+
+               $this->assertEquals(
+                       [ 'Foo bar', 'New page' ],
+                       $searchDataExtractor->getCategories( $parserOutput )
+               );
+       }
+
+       public function testGetExternalLinks() {
+               $parserOutput = new ParserOutput();
+
+               $parserOutput->addExternalLink( 'https://foo' );
+               $parserOutput->addExternalLink( 'https://bar' );
+
+               $searchDataExtractor = new ParserOutputSearchDataExtractor();
+
+               $this->assertEquals(
+                       [ 'https://foo', 'https://bar' ],
+                       $searchDataExtractor->getExternalLinks( $parserOutput )
+               );
+       }
+
+       public function testGetOutgoingLinks() {
+               $parserOutput = new ParserOutput();
+
+               $parserOutput->addLink( Title::makeTitle( NS_MAIN, 'Foo_bar' ), 1 );
+               $parserOutput->addLink( Title::makeTitle( NS_HELP, 'Contents' ), 2 );
+
+               $searchDataExtractor = new ParserOutputSearchDataExtractor();
+
+               // this indexes links with db key
+               $this->assertEquals(
+                       [ 'Foo_bar', 'Help:Contents' ],
+                       $searchDataExtractor->getOutgoingLinks( $parserOutput )
+               );
+       }
+
+       public function testGetTemplates() {
+               $title = Title::makeTitle( NS_TEMPLATE, 'Cite_news' );
+
+               $parserOutput = new ParserOutput();
+               $parserOutput->addTemplate( $title, 10, 100 );
+
+               $searchDataExtractor = new ParserOutputSearchDataExtractor();
+
+               $this->assertEquals(
+                       [ 'Template:Cite news' ],
+                       $searchDataExtractor->getTemplates( $parserOutput )
+               );
+       }
+
+}
diff --git a/tests/phpunit/specials/SpecialSearchTest.php b/tests/phpunit/specials/SpecialSearchTest.php
new file mode 100644 (file)
index 0000000..20e88f5
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+
+class SpecialSearchText extends \PHPUnit_Framework_TestCase {
+       public function testSubPageRedirect() {
+               $ctx = new RequestContext;
+
+               SpecialPageFactory::executePath(
+                       Title::newFromText( 'Special:Search/foo_bar' ),
+                       $ctx
+               );
+               $url = $ctx->getOutput()->getRedirect();
+               // some older versions of hhvm have a bug that doesn't parse relative
+               // urls with a port, so help it out a little bit.
+               // https://github.com/facebook/hhvm/issues/7136
+               $url = wfExpandUrl( $url, PROTO_CURRENT );
+
+               $parts = parse_url( $url );
+               $this->assertEquals( '/w/index.php', $parts['path'] );
+               parse_str( $parts['query'], $query );
+               $this->assertEquals( 'Special:Search', $query['title'] );
+               $this->assertEquals( 'foo bar', $query['search'] );
+       }
+}
index 6446416..86ce53f 100644 (file)
@@ -86,6 +86,25 @@ class ResourcesTest extends MediaWikiTestCase {
                }
        }
 
+       /**
+        * Verify that all specified messages actually exist.
+        */
+       public function testMissingMessages() {
+               $data = self::getAllModules();
+               $validDeps = array_keys( $data['modules'] );
+               $lang = Language::factory( 'en' );
+
+               /** @var ResourceLoaderModule $module */
+               foreach ( $data['modules'] as $moduleName => $module ) {
+                       foreach ( $module->getMessages() as $msgKey ) {
+                               $this->assertTrue(
+                                       wfMessage( $msgKey )->useDatabase( false )->inLanguage( $lang )->exists(),
+                                       "Message '$msgKey' required by '$moduleName' must exist"
+                               );
+                       }
+               }
+       }
+
        /**
         * Verify that all dependencies of all modules are always satisfiable with the 'targets' defined
         * for the involved modules.
index 95f28c8..e30088d 100644 (file)
@@ -74,6 +74,7 @@ return [
                        'tests/qunit/suites/resources/mediawiki/mediawiki.template.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.template.mustache.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.test.js',
+                       'tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.html.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.toc.test.js',
diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js
new file mode 100644 (file)
index 0000000..41d800a
--- /dev/null
@@ -0,0 +1,685 @@
+( function ( mw, $ ) {
+       QUnit.module( 'mediawiki (mw.loader)' );
+
+       mw.loader.addSource(
+               'testloader',
+               QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/load.mock.php' )
+       );
+
+       /**
+        * The sync style load test (for @import). This is, in a way, also an open bug for
+        * ResourceLoader ("execute js after styles are loaded"), but browsers don't offer a
+        * way to get a callback from when a stylesheet is loaded (that is, including any
+        * `@import` rules inside). To work around this, we'll have a little time loop to check
+        * if the styles apply.
+        *
+        * Note: This test originally used new Image() and onerror to get a callback
+        * when the url is loaded, but that is fragile since it doesn't monitor the
+        * same request as the css @import, and Safari 4 has issues with
+        * onerror/onload not being fired at all in weird cases like this.
+        */
+       function assertStyleAsync( assert, $element, prop, val, fn ) {
+               var styleTestStart,
+                       el = $element.get( 0 ),
+                       styleTestTimeout = ( QUnit.config.testTimeout || 5000 ) - 200;
+
+               function isCssImportApplied() {
+                       // Trigger reflow, repaint, redraw, whatever (cross-browser)
+                       var x = $element.css( 'height' );
+                       x = el.innerHTML;
+                       el.className = el.className;
+                       x = document.documentElement.clientHeight;
+
+                       return $element.css( prop ) === val;
+               }
+
+               function styleTestLoop() {
+                       var styleTestSince = new Date().getTime() - styleTestStart;
+                       // If it is passing or if we timed out, run the real test and stop the loop
+                       if ( isCssImportApplied() || styleTestSince > styleTestTimeout ) {
+                               assert.equal( $element.css( prop ), val,
+                                       'style "' + prop + ': ' + val + '" from url is applied (after ' + styleTestSince + 'ms)'
+                               );
+
+                               if ( fn ) {
+                                       fn();
+                               }
+
+                               return;
+                       }
+                       // Otherwise, keep polling
+                       setTimeout( styleTestLoop );
+               }
+
+               // Start the loop
+               styleTestStart = new Date().getTime();
+               styleTestLoop();
+       }
+
+       function urlStyleTest( selector, prop, val ) {
+               return QUnit.fixurl(
+                       mw.config.get( 'wgScriptPath' ) +
+                               '/tests/qunit/data/styleTest.css.php?' +
+                               $.param( {
+                                       selector: selector,
+                                       prop: prop,
+                                       val: val
+                               } )
+               );
+       }
+
+       QUnit.test( 'Basic', 2, function ( assert ) {
+               var isAwesomeDone;
+
+               mw.loader.testCallback = function () {
+                       assert.strictEqual( isAwesomeDone, undefined, 'Implementing module is.awesome: isAwesomeDone should still be undefined' );
+                       isAwesomeDone = true;
+               };
+
+               mw.loader.implement( 'test.callback', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/callMwLoaderTestCallback.js' ) ] );
+
+               return mw.loader.using( 'test.callback', function () {
+                       assert.strictEqual( isAwesomeDone, true, 'test.callback module should\'ve caused isAwesomeDone to be true' );
+                       delete mw.loader.testCallback;
+
+               }, function () {
+                       assert.ok( false, 'Error callback fired while loader.using "test.callback" module' );
+               } );
+       } );
+
+       QUnit.test( 'Object method as module name', 2, function ( assert ) {
+               var isAwesomeDone;
+
+               mw.loader.testCallback = function () {
+                       assert.strictEqual( isAwesomeDone, undefined, 'Implementing module hasOwnProperty: isAwesomeDone should still be undefined' );
+                       isAwesomeDone = true;
+               };
+
+               mw.loader.implement( 'hasOwnProperty', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/callMwLoaderTestCallback.js' ) ], {}, {} );
+
+               return mw.loader.using( 'hasOwnProperty', function () {
+                       assert.strictEqual( isAwesomeDone, true, 'hasOwnProperty module should\'ve caused isAwesomeDone to be true' );
+                       delete mw.loader.testCallback;
+
+               }, function () {
+                       assert.ok( false, 'Error callback fired while loader.using "hasOwnProperty" module' );
+               } );
+       } );
+
+       QUnit.test( '.using( .. ) Promise', 2, function ( assert ) {
+               var isAwesomeDone;
+
+               mw.loader.testCallback = function () {
+                       assert.strictEqual( isAwesomeDone, undefined, 'Implementing module is.awesome: isAwesomeDone should still be undefined' );
+                       isAwesomeDone = true;
+               };
+
+               mw.loader.implement( 'test.promise', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/callMwLoaderTestCallback.js' ) ] );
+
+               return mw.loader.using( 'test.promise' )
+               .done( function () {
+                       assert.strictEqual( isAwesomeDone, true, 'test.promise module should\'ve caused isAwesomeDone to be true' );
+                       delete mw.loader.testCallback;
+
+               } )
+               .fail( function () {
+                       assert.ok( false, 'Error callback fired while loader.using "test.promise" module' );
+               } );
+       } );
+
+       QUnit.test( '.implement( styles={ "css": [text, ..] } )', 2, function ( assert ) {
+               var $element = $( '<div class="mw-test-implement-a"></div>' ).appendTo( '#qunit-fixture' );
+
+               assert.notEqual(
+                       $element.css( 'float' ),
+                       'right',
+                       'style is clear'
+               );
+
+               mw.loader.implement(
+                       'test.implement.a',
+                       function () {
+                               assert.equal(
+                                       $element.css( 'float' ),
+                                       'right',
+                                       'style is applied'
+                               );
+                       },
+                       {
+                               all: '.mw-test-implement-a { float: right; }'
+                       }
+               );
+
+               return mw.loader.using( 'test.implement.a' );
+       } );
+
+       QUnit.test( '.implement( styles={ "url": { <media>: [url, ..] } } )', 7, function ( assert ) {
+               var $element1 = $( '<div class="mw-test-implement-b1"></div>' ).appendTo( '#qunit-fixture' ),
+                       $element2 = $( '<div class="mw-test-implement-b2"></div>' ).appendTo( '#qunit-fixture' ),
+                       $element3 = $( '<div class="mw-test-implement-b3"></div>' ).appendTo( '#qunit-fixture' ),
+                       done = assert.async();
+
+               assert.notEqual(
+                       $element1.css( 'text-align' ),
+                       'center',
+                       'style is clear'
+               );
+               assert.notEqual(
+                       $element2.css( 'float' ),
+                       'left',
+                       'style is clear'
+               );
+               assert.notEqual(
+                       $element3.css( 'text-align' ),
+                       'right',
+                       'style is clear'
+               );
+
+               mw.loader.implement(
+                       'test.implement.b',
+                       function () {
+                               // Note: done() must only be called when the entire test is
+                               // complete. So, make sure that we don't start until *both*
+                               // assertStyleAsync calls have completed.
+                               var pending = 2;
+                               assertStyleAsync( assert, $element2, 'float', 'left', function () {
+                                       assert.notEqual( $element1.css( 'text-align' ), 'center', 'print style is not applied' );
+
+                                       pending--;
+                                       if ( pending === 0 ) {
+                                               done();
+                                       }
+                               } );
+                               assertStyleAsync( assert, $element3, 'float', 'right', function () {
+                                       assert.notEqual( $element1.css( 'text-align' ), 'center', 'print style is not applied' );
+
+                                       pending--;
+                                       if ( pending === 0 ) {
+                                               done();
+                                       }
+                               } );
+                       },
+                       {
+                               url: {
+                                       print: [ urlStyleTest( '.mw-test-implement-b1', 'text-align', 'center' ) ],
+                                       screen: [
+                                               // bug 40834: Make sure it actually works with more than 1 stylesheet reference
+                                               urlStyleTest( '.mw-test-implement-b2', 'float', 'left' ),
+                                               urlStyleTest( '.mw-test-implement-b3', 'float', 'right' )
+                                       ]
+                               }
+                       }
+               );
+
+               mw.loader.load( 'test.implement.b' );
+       } );
+
+       // Backwards compatibility
+       QUnit.test( '.implement( styles={ <media>: text } ) (back-compat)', 2, function ( assert ) {
+               var $element = $( '<div class="mw-test-implement-c"></div>' ).appendTo( '#qunit-fixture' );
+
+               assert.notEqual(
+                       $element.css( 'float' ),
+                       'right',
+                       'style is clear'
+               );
+
+               mw.loader.implement(
+                       'test.implement.c',
+                       function () {
+                               assert.equal(
+                                       $element.css( 'float' ),
+                                       'right',
+                                       'style is applied'
+                               );
+                       },
+                       {
+                               all: '.mw-test-implement-c { float: right; }'
+                       }
+               );
+
+               return mw.loader.using( 'test.implement.c' );
+       } );
+
+       // Backwards compatibility
+       QUnit.test( '.implement( styles={ <media>: [url, ..] } ) (back-compat)', 4, function ( assert ) {
+               var $element = $( '<div class="mw-test-implement-d"></div>' ).appendTo( '#qunit-fixture' ),
+                       $element2 = $( '<div class="mw-test-implement-d2"></div>' ).appendTo( '#qunit-fixture' ),
+                       done = assert.async();
+
+               assert.notEqual(
+                       $element.css( 'float' ),
+                       'right',
+                       'style is clear'
+               );
+               assert.notEqual(
+                       $element2.css( 'text-align' ),
+                       'center',
+                       'style is clear'
+               );
+
+               mw.loader.implement(
+                       'test.implement.d',
+                       function () {
+                               assertStyleAsync( assert, $element, 'float', 'right', function () {
+                                       assert.notEqual( $element2.css( 'text-align' ), 'center', 'print style is not applied (bug 40500)' );
+                                       done();
+                               } );
+                       },
+                       {
+                               all: [ urlStyleTest( '.mw-test-implement-d', 'float', 'right' ) ],
+                               print: [ urlStyleTest( '.mw-test-implement-d2', 'text-align', 'center' ) ]
+                       }
+               );
+
+               mw.loader.load( 'test.implement.d' );
+       } );
+
+       // @import (bug 31676)
+       QUnit.test( '.implement( styles has @import )', 7, function ( assert ) {
+               var isJsExecuted, $element,
+                       done = assert.async();
+
+               mw.loader.implement(
+                       'test.implement.import',
+                       function () {
+                               assert.strictEqual( isJsExecuted, undefined, 'script not executed multiple times' );
+                               isJsExecuted = true;
+
+                               assert.equal( mw.loader.getState( 'test.implement.import' ), 'executing', 'module state during implement() script execution' );
+
+                               $element = $( '<div class="mw-test-implement-import">Foo bar</div>' ).appendTo( '#qunit-fixture' );
+
+                               assert.equal( mw.msg( 'test-foobar' ), 'Hello Foobar, $1!', 'messages load before script execution' );
+
+                               assertStyleAsync( assert, $element, 'float', 'right', function () {
+                                       assert.equal( $element.css( 'text-align' ), 'center',
+                                               'CSS styles after the @import rule are working'
+                                       );
+
+                                       done();
+                               } );
+                       },
+                       {
+                               css: [
+                                       '@import url(\''
+                                               + urlStyleTest( '.mw-test-implement-import', 'float', 'right' )
+                                               + '\');\n'
+                                               + '.mw-test-implement-import { text-align: center; }'
+                               ]
+                       },
+                       {
+                               'test-foobar': 'Hello Foobar, $1!'
+                       }
+               );
+
+               mw.loader.using( 'test.implement.import' ).always( function () {
+                       assert.strictEqual( isJsExecuted, true, 'script executed' );
+                       assert.equal( mw.loader.getState( 'test.implement.import' ), 'ready', 'module state after script execution' );
+               } );
+       } );
+
+       QUnit.test( '.implement( dependency with styles )', 4, function ( assert ) {
+               var $element = $( '<div class="mw-test-implement-e"></div>' ).appendTo( '#qunit-fixture' ),
+                       $element2 = $( '<div class="mw-test-implement-e2"></div>' ).appendTo( '#qunit-fixture' );
+
+               assert.notEqual(
+                       $element.css( 'float' ),
+                       'right',
+                       'style is clear'
+               );
+               assert.notEqual(
+                       $element2.css( 'float' ),
+                       'left',
+                       'style is clear'
+               );
+
+               mw.loader.register( [
+                       [ 'test.implement.e', '0', [ 'test.implement.e2' ] ],
+                       [ 'test.implement.e2', '0' ]
+               ] );
+
+               mw.loader.implement(
+                       'test.implement.e',
+                       function () {
+                               assert.equal(
+                                       $element.css( 'float' ),
+                                       'right',
+                                       'Depending module\'s style is applied'
+                               );
+                       },
+                       {
+                               all: '.mw-test-implement-e { float: right; }'
+                       }
+               );
+
+               mw.loader.implement(
+                       'test.implement.e2',
+                       function () {
+                               assert.equal(
+                                       $element2.css( 'float' ),
+                                       'left',
+                                       'Dependency\'s style is applied'
+                               );
+                       },
+                       {
+                               all: '.mw-test-implement-e2 { float: left; }'
+                       }
+               );
+
+               return mw.loader.using( 'test.implement.e' );
+       } );
+
+       QUnit.test( '.implement( only scripts )', 1, function ( assert ) {
+               mw.loader.implement( 'test.onlyscripts', function () {} );
+               assert.strictEqual( mw.loader.getState( 'test.onlyscripts' ), 'ready' );
+       } );
+
+       QUnit.test( '.implement( only messages )', 2, function ( assert ) {
+               assert.assertFalse( mw.messages.exists( 'bug_29107' ), 'Verify that the test message doesn\'t exist yet' );
+
+               // jscs: disable requireCamelCaseOrUpperCaseIdentifiers
+               mw.loader.implement( 'test.implement.msgs', [], {}, { bug_29107: 'loaded' } );
+               // jscs: enable requireCamelCaseOrUpperCaseIdentifiers
+
+               return mw.loader.using( 'test.implement.msgs', function () {
+                       assert.ok( mw.messages.exists( 'bug_29107' ), 'Bug 29107: messages-only module should implement ok' );
+               }, function () {
+                       assert.ok( false, 'Error callback fired while implementing "test.implement.msgs" module' );
+               } );
+       } );
+
+       QUnit.test( '.implement( empty )', 1, function ( assert ) {
+               mw.loader.implement( 'test.empty' );
+               assert.strictEqual( mw.loader.getState( 'test.empty' ), 'ready' );
+       } );
+
+       QUnit.test( 'Broken indirect dependency', 4, function ( assert ) {
+               // don't emit an error event
+               this.sandbox.stub( mw, 'track' );
+
+               mw.loader.register( [
+                       [ 'test.module1', '0' ],
+                       [ 'test.module2', '0', [ 'test.module1' ] ],
+                       [ 'test.module3', '0', [ 'test.module2' ] ]
+               ] );
+               mw.loader.implement( 'test.module1', function () {
+                       throw new Error( 'expected' );
+               }, {}, {} );
+               assert.strictEqual( mw.loader.getState( 'test.module1' ), 'error', 'Expected "error" state for test.module1' );
+               assert.strictEqual( mw.loader.getState( 'test.module2' ), 'error', 'Expected "error" state for test.module2' );
+               assert.strictEqual( mw.loader.getState( 'test.module3' ), 'error', 'Expected "error" state for test.module3' );
+
+               assert.strictEqual( mw.track.callCount, 1 );
+       } );
+
+       QUnit.test( 'Circular dependency', 1, function ( assert ) {
+               mw.loader.register( [
+                       [ 'test.circle1', '0', [ 'test.circle2' ] ],
+                       [ 'test.circle2', '0', [ 'test.circle3' ] ],
+                       [ 'test.circle3', '0', [ 'test.circle1' ] ]
+               ] );
+               assert.throws( function () {
+                       mw.loader.using( 'test.circle3' );
+               }, /Circular/, 'Detect circular dependency' );
+       } );
+
+       QUnit.test( 'Out-of-order implementation', 9, function ( assert ) {
+               mw.loader.register( [
+                       [ 'test.module4', '0' ],
+                       [ 'test.module5', '0', [ 'test.module4' ] ],
+                       [ 'test.module6', '0', [ 'test.module5' ] ]
+               ] );
+               mw.loader.implement( 'test.module4', function () {} );
+               assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' );
+               assert.strictEqual( mw.loader.getState( 'test.module5' ), 'registered', 'Expected "registered" state for test.module5' );
+               assert.strictEqual( mw.loader.getState( 'test.module6' ), 'registered', 'Expected "registered" state for test.module6' );
+               mw.loader.implement( 'test.module6', function () {} );
+               assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' );
+               assert.strictEqual( mw.loader.getState( 'test.module5' ), 'registered', 'Expected "registered" state for test.module5' );
+               assert.strictEqual( mw.loader.getState( 'test.module6' ), 'loaded', 'Expected "loaded" state for test.module6' );
+               mw.loader.implement( 'test.module5', function () {} );
+               assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' );
+               assert.strictEqual( mw.loader.getState( 'test.module5' ), 'ready', 'Expected "ready" state for test.module5' );
+               assert.strictEqual( mw.loader.getState( 'test.module6' ), 'ready', 'Expected "ready" state for test.module6' );
+       } );
+
+       QUnit.test( 'Missing dependency', 13, function ( assert ) {
+               mw.loader.register( [
+                       [ 'test.module7', '0' ],
+                       [ 'test.module8', '0', [ 'test.module7' ] ],
+                       [ 'test.module9', '0', [ 'test.module8' ] ]
+               ] );
+               mw.loader.implement( 'test.module8', function () {} );
+               assert.strictEqual( mw.loader.getState( 'test.module7' ), 'registered', 'Expected "registered" state for test.module7' );
+               assert.strictEqual( mw.loader.getState( 'test.module8' ), 'loaded', 'Expected "loaded" state for test.module8' );
+               assert.strictEqual( mw.loader.getState( 'test.module9' ), 'registered', 'Expected "registered" state for test.module9' );
+               mw.loader.state( 'test.module7', 'missing' );
+               assert.strictEqual( mw.loader.getState( 'test.module7' ), 'missing', 'Expected "missing" state for test.module7' );
+               assert.strictEqual( mw.loader.getState( 'test.module8' ), 'error', 'Expected "error" state for test.module8' );
+               assert.strictEqual( mw.loader.getState( 'test.module9' ), 'error', 'Expected "error" state for test.module9' );
+               mw.loader.implement( 'test.module9', function () {} );
+               assert.strictEqual( mw.loader.getState( 'test.module7' ), 'missing', 'Expected "missing" state for test.module7' );
+               assert.strictEqual( mw.loader.getState( 'test.module8' ), 'error', 'Expected "error" state for test.module8' );
+               assert.strictEqual( mw.loader.getState( 'test.module9' ), 'error', 'Expected "error" state for test.module9' );
+               mw.loader.using(
+                       [ 'test.module7' ],
+                       function () {
+                               assert.ok( false, 'Success fired despite missing dependency' );
+                               assert.ok( true, 'QUnit expected() count dummy' );
+                       },
+                       function ( e, dependencies ) {
+                               assert.strictEqual( $.isArray( dependencies ), true, 'Expected array of dependencies' );
+                               assert.deepEqual( dependencies, [ 'test.module7' ], 'Error callback called with module test.module7' );
+                       }
+               );
+               mw.loader.using(
+                       [ 'test.module9' ],
+                       function () {
+                               assert.ok( false, 'Success fired despite missing dependency' );
+                               assert.ok( true, 'QUnit expected() count dummy' );
+                       },
+                       function ( e, dependencies ) {
+                               assert.strictEqual( $.isArray( dependencies ), true, 'Expected array of dependencies' );
+                               dependencies.sort();
+                               assert.deepEqual(
+                                       dependencies,
+                                       [ 'test.module7', 'test.module8', 'test.module9' ],
+                                       'Error callback called with all three modules as dependencies'
+                               );
+                       }
+               );
+       } );
+
+       QUnit.test( 'Dependency handling', 5, function ( assert ) {
+               var done = assert.async();
+               mw.loader.register( [
+                       // [module, version, dependencies, group, source]
+                       [ 'testMissing', '1', [], null, 'testloader' ],
+                       [ 'testUsesMissing', '1', [ 'testMissing' ], null, 'testloader' ],
+                       [ 'testUsesNestedMissing', '1', [ 'testUsesMissing' ], null, 'testloader' ]
+               ] );
+
+               function verifyModuleStates() {
+                       assert.equal( mw.loader.getState( 'testMissing' ), 'missing', 'Module not known to server must have state "missing"' );
+                       assert.equal( mw.loader.getState( 'testUsesMissing' ), 'error', 'Module with missing dependency must have state "error"' );
+                       assert.equal( mw.loader.getState( 'testUsesNestedMissing' ), 'error', 'Module with indirect missing dependency must have state "error"' );
+               }
+
+               mw.loader.using( [ 'testUsesNestedMissing' ],
+                       function () {
+                               assert.ok( false, 'Error handler should be invoked.' );
+                               assert.ok( true ); // Dummy to reach QUnit expect()
+
+                               verifyModuleStates();
+
+                               done();
+                       },
+                       function ( e, badmodules ) {
+                               assert.ok( true, 'Error handler should be invoked.' );
+                               // As soon as server spits out state('testMissing', 'missing');
+                               // it will bubble up and trigger the error callback.
+                               // Therefor the badmodules array is not testUsesMissing or testUsesNestedMissing.
+                               assert.deepEqual( badmodules, [ 'testMissing' ], 'Bad modules as expected.' );
+
+                               verifyModuleStates();
+
+                               done();
+                       }
+               );
+       } );
+
+       QUnit.test( 'Skip-function handling', 5, function ( assert ) {
+               mw.loader.register( [
+                       // [module, version, dependencies, group, source, skip]
+                       [ 'testSkipped', '1', [], null, 'testloader', 'return true;' ],
+                       [ 'testNotSkipped', '1', [], null, 'testloader', 'return false;' ],
+                       [ 'testUsesSkippable', '1', [ 'testSkipped', 'testNotSkipped' ], null, 'testloader' ]
+               ] );
+
+               function verifyModuleStates() {
+                       assert.equal( mw.loader.getState( 'testSkipped' ), 'ready', 'Module is ready when skipped' );
+                       assert.equal( mw.loader.getState( 'testNotSkipped' ), 'ready', 'Module is ready when not skipped but loaded' );
+                       assert.equal( mw.loader.getState( 'testUsesSkippable' ), 'ready', 'Module is ready when skippable dependencies are ready' );
+               }
+
+               return mw.loader.using( [ 'testUsesSkippable' ],
+                       function () {
+                               assert.ok( true, 'Success handler should be invoked.' );
+                               assert.ok( true ); // Dummy to match error handler and reach QUnit expect()
+
+                               verifyModuleStates();
+                       },
+                       function ( e, badmodules ) {
+                               assert.ok( false, 'Error handler should not be invoked.' );
+                               assert.deepEqual( badmodules, [], 'Bad modules as expected.' );
+
+                               verifyModuleStates();
+                       }
+               );
+       } );
+
+       QUnit.asyncTest( '.load( "//protocol-relative" ) - T32825', 2, function ( assert ) {
+               // This bug was actually already fixed in 1.18 and later when discovered in 1.17.
+               // Test is for regressions!
+
+               // Forge a URL to the test callback script
+               var target = QUnit.fixurl(
+                       mw.config.get( 'wgServer' ) + mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/qunitOkCall.js'
+               );
+
+               // Confirm that mw.loader.load() works with protocol-relative URLs
+               target = target.replace( /https?:/, '' );
+
+               assert.equal( target.slice( 0, 2 ), '//',
+                       'URL must be relative to test relative URLs!'
+               );
+
+               // Async!
+               // The target calls QUnit.start
+               mw.loader.load( target );
+       } );
+
+       QUnit.asyncTest( '.load( "/absolute-path" )', 2, function ( assert ) {
+               // Forge a URL to the test callback script
+               var target = QUnit.fixurl(
+                       mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/qunitOkCall.js'
+               );
+
+               // Confirm that mw.loader.load() works with absolute-paths (relative to current hostname)
+               assert.equal( target.slice( 0, 1 ), '/', 'URL is relative to document root' );
+
+               // Async!
+               // The target calls QUnit.start
+               mw.loader.load( target );
+       } );
+
+       QUnit.test( 'Executing race - T112232', 2, function ( assert ) {
+               var done = false;
+
+               // The red herring schedules its CSS buffer first. In T112232, a bug in the
+               // state machine would cause the job for testRaceLoadMe to run with an earlier job.
+               mw.loader.implement(
+                       'testRaceRedHerring',
+                       function () {},
+                       { css: [ '.mw-testRaceRedHerring {}' ] }
+               );
+               mw.loader.implement(
+                       'testRaceLoadMe',
+                       function () {
+                               done = true;
+                       },
+                       { css: [ '.mw-testRaceLoadMe { float: left; }' ] }
+               );
+
+               mw.loader.load( [ 'testRaceRedHerring', 'testRaceLoadMe' ] );
+               return mw.loader.using( 'testRaceLoadMe', function () {
+                       assert.strictEqual( done, true, 'script ran' );
+                       assert.strictEqual( mw.loader.getState( 'testRaceLoadMe' ), 'ready', 'state' );
+               } );
+       } );
+
+       QUnit.test( 'require()', 6, function ( assert ) {
+               mw.loader.register( [
+                       [ 'test.require1', '0' ],
+                       [ 'test.require2', '0' ],
+                       [ 'test.require3', '0' ],
+                       [ 'test.require4', '0', [ 'test.require3' ] ]
+               ] );
+               mw.loader.implement( 'test.require1', function () {} );
+               mw.loader.implement( 'test.require2', function ( $, jQuery, require, module ) {
+                       module.exports = 1;
+               } );
+               mw.loader.implement( 'test.require3', function ( $, jQuery, require, module ) {
+                       module.exports = function () {
+                               return 'hello world';
+                       };
+               } );
+               mw.loader.implement( 'test.require4', function ( $, jQuery, require, module ) {
+                       var other = require( 'test.require3' );
+                       module.exports = {
+                               pizza: function () {
+                                       return other();
+                               }
+                       };
+               } );
+               return mw.loader.using( [ 'test.require1', 'test.require2', 'test.require3', 'test.require4' ] )
+               .then( function ( require ) {
+                       var module1, module2, module3, module4;
+
+                       module1 = require( 'test.require1' );
+                       module2 = require( 'test.require2' );
+                       module3 = require( 'test.require3' );
+                       module4 = require( 'test.require4' );
+
+                       assert.strictEqual( typeof module1, 'object', 'export of module with no export' );
+                       assert.strictEqual( module2, 1, 'export a number' );
+                       assert.strictEqual( module3(), 'hello world', 'export a function' );
+                       assert.strictEqual( typeof module4.pizza, 'function', 'export an object' );
+                       assert.strictEqual( module4.pizza(), 'hello world', 'module can require other modules' );
+
+                       assert.throws( function () {
+                               require( '_badmodule' );
+                       }, /is not loaded/, 'Requesting non-existent modules throws error.' );
+               } );
+       } );
+
+       QUnit.test( 'require() in debug mode', 1, function ( assert ) {
+               var path = mw.config.get( 'wgScriptPath' );
+               mw.loader.register( [
+                       [ 'test.require.define', '0' ],
+                       [ 'test.require.callback', '0', [ 'test.require.define' ] ]
+               ] );
+               mw.loader.implement( 'test.require.callback', [ QUnit.fixurl( path + '/tests/qunit/data/requireCallMwLoaderTestCallback.js' ) ] );
+               mw.loader.implement( 'test.require.define', [ QUnit.fixurl( path + '/tests/qunit/data/defineCallMwLoaderTestCallback.js' ) ] );
+
+               return mw.loader.using( 'test.require.callback' ).then( function ( require ) {
+                       var exported = require( 'test.require.callback' );
+                       assert.strictEqual( exported, 'Require worked.Define worked.',
+                               'module.exports worked in debug mode' );
+               }, function () {
+                       assert.ok( false, 'Error callback fired while loader.using "test.require.callback" module' );
+               } );
+       } );
+
+}( mediaWiki, jQuery ) );
index baec37c..ab463a9 100644 (file)
@@ -1,5 +1,5 @@
 /*jshint -W024 */
-( function ( mw, $ ) {
+( function ( mw ) {
        var specialCharactersPageName,
                // Can't mock SITENAME since jqueryMsg caches it at load
                siteName = mw.config.get( 'wgSiteName' );
                }
        } ) );
 
-       mw.loader.addSource(
-               'testloader',
-               QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/load.mock.php' )
-       );
-
        QUnit.test( 'Initial check', 8, function ( assert ) {
                assert.ok( window.jQuery, 'jQuery defined' );
                assert.ok( window.$, '$ defined' );
                assert.equal( mw.msg( 'int-msg' ), 'Some Other Message', 'int is resolved' );
        } );
 
-       /**
-        * The sync style load test (for @import). This is, in a way, also an open bug for
-        * ResourceLoader ("execute js after styles are loaded"), but browsers don't offer a
-        * way to get a callback from when a stylesheet is loaded (that is, including any
-        * `@import` rules inside). To work around this, we'll have a little time loop to check
-        * if the styles apply.
-        *
-        * Note: This test originally used new Image() and onerror to get a callback
-        * when the url is loaded, but that is fragile since it doesn't monitor the
-        * same request as the css @import, and Safari 4 has issues with
-        * onerror/onload not being fired at all in weird cases like this.
-        */
-       function assertStyleAsync( assert, $element, prop, val, fn ) {
-               var styleTestStart,
-                       el = $element.get( 0 ),
-                       styleTestTimeout = ( QUnit.config.testTimeout || 5000 ) - 200;
-
-               function isCssImportApplied() {
-                       // Trigger reflow, repaint, redraw, whatever (cross-browser)
-                       var x = $element.css( 'height' );
-                       x = el.innerHTML;
-                       el.className = el.className;
-                       x = document.documentElement.clientHeight;
-
-                       return $element.css( prop ) === val;
-               }
-
-               function styleTestLoop() {
-                       var styleTestSince = new Date().getTime() - styleTestStart;
-                       // If it is passing or if we timed out, run the real test and stop the loop
-                       if ( isCssImportApplied() || styleTestSince > styleTestTimeout ) {
-                               assert.equal( $element.css( prop ), val,
-                                       'style "' + prop + ': ' + val + '" from url is applied (after ' + styleTestSince + 'ms)'
-                               );
-
-                               if ( fn ) {
-                                       fn();
-                               }
-
-                               return;
-                       }
-                       // Otherwise, keep polling
-                       setTimeout( styleTestLoop );
-               }
-
-               // Start the loop
-               styleTestStart = new Date().getTime();
-               styleTestLoop();
-       }
-
-       function urlStyleTest( selector, prop, val ) {
-               return QUnit.fixurl(
-                       mw.config.get( 'wgScriptPath' ) +
-                               '/tests/qunit/data/styleTest.css.php?' +
-                               $.param( {
-                                       selector: selector,
-                                       prop: prop,
-                                       val: val
-                               } )
-               );
-       }
-
-       QUnit.test( 'mw.loader', 2, function ( assert ) {
-               var isAwesomeDone;
-
-               mw.loader.testCallback = function () {
-                       assert.strictEqual( isAwesomeDone, undefined, 'Implementing module is.awesome: isAwesomeDone should still be undefined' );
-                       isAwesomeDone = true;
-               };
-
-               mw.loader.implement( 'test.callback', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/callMwLoaderTestCallback.js' ) ] );
-
-               return mw.loader.using( 'test.callback', function () {
-                       assert.strictEqual( isAwesomeDone, true, 'test.callback module should\'ve caused isAwesomeDone to be true' );
-                       delete mw.loader.testCallback;
-
-               }, function () {
-                       assert.ok( false, 'Error callback fired while loader.using "test.callback" module' );
-               } );
-       } );
-
-       QUnit.test( 'mw.loader with Object method as module name', 2, function ( assert ) {
-               var isAwesomeDone;
-
-               mw.loader.testCallback = function () {
-                       assert.strictEqual( isAwesomeDone, undefined, 'Implementing module hasOwnProperty: isAwesomeDone should still be undefined' );
-                       isAwesomeDone = true;
-               };
-
-               mw.loader.implement( 'hasOwnProperty', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/callMwLoaderTestCallback.js' ) ], {}, {} );
-
-               return mw.loader.using( 'hasOwnProperty', function () {
-                       assert.strictEqual( isAwesomeDone, true, 'hasOwnProperty module should\'ve caused isAwesomeDone to be true' );
-                       delete mw.loader.testCallback;
-
-               }, function () {
-                       assert.ok( false, 'Error callback fired while loader.using "hasOwnProperty" module' );
-               } );
-       } );
-
-       QUnit.test( 'mw.loader.using( .. ) Promise', 2, function ( assert ) {
-               var isAwesomeDone;
-
-               mw.loader.testCallback = function () {
-                       assert.strictEqual( isAwesomeDone, undefined, 'Implementing module is.awesome: isAwesomeDone should still be undefined' );
-                       isAwesomeDone = true;
-               };
-
-               mw.loader.implement( 'test.promise', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/callMwLoaderTestCallback.js' ) ] );
-
-               return mw.loader.using( 'test.promise' )
-               .done( function () {
-                       assert.strictEqual( isAwesomeDone, true, 'test.promise module should\'ve caused isAwesomeDone to be true' );
-                       delete mw.loader.testCallback;
-
-               } )
-               .fail( function () {
-                       assert.ok( false, 'Error callback fired while loader.using "test.promise" module' );
-               } );
-       } );
-
-       QUnit.test( 'mw.loader.implement( styles={ "css": [text, ..] } )', 2, function ( assert ) {
-               var $element = $( '<div class="mw-test-implement-a"></div>' ).appendTo( '#qunit-fixture' );
-
-               assert.notEqual(
-                       $element.css( 'float' ),
-                       'right',
-                       'style is clear'
-               );
-
-               mw.loader.implement(
-                       'test.implement.a',
-                       function () {
-                               assert.equal(
-                                       $element.css( 'float' ),
-                                       'right',
-                                       'style is applied'
-                               );
-                       },
-                       {
-                               all: '.mw-test-implement-a { float: right; }'
-                       }
-               );
-
-               return mw.loader.using( 'test.implement.a' );
-       } );
-
-       QUnit.test( 'mw.loader.implement( styles={ "url": { <media>: [url, ..] } } )', 7, function ( assert ) {
-               var $element1 = $( '<div class="mw-test-implement-b1"></div>' ).appendTo( '#qunit-fixture' ),
-                       $element2 = $( '<div class="mw-test-implement-b2"></div>' ).appendTo( '#qunit-fixture' ),
-                       $element3 = $( '<div class="mw-test-implement-b3"></div>' ).appendTo( '#qunit-fixture' ),
-                       done = assert.async();
-
-               assert.notEqual(
-                       $element1.css( 'text-align' ),
-                       'center',
-                       'style is clear'
-               );
-               assert.notEqual(
-                       $element2.css( 'float' ),
-                       'left',
-                       'style is clear'
-               );
-               assert.notEqual(
-                       $element3.css( 'text-align' ),
-                       'right',
-                       'style is clear'
-               );
-
-               mw.loader.implement(
-                       'test.implement.b',
-                       function () {
-                               // Note: done() must only be called when the entire test is
-                               // complete. So, make sure that we don't start until *both*
-                               // assertStyleAsync calls have completed.
-                               var pending = 2;
-                               assertStyleAsync( assert, $element2, 'float', 'left', function () {
-                                       assert.notEqual( $element1.css( 'text-align' ), 'center', 'print style is not applied' );
-
-                                       pending--;
-                                       if ( pending === 0 ) {
-                                               done();
-                                       }
-                               } );
-                               assertStyleAsync( assert, $element3, 'float', 'right', function () {
-                                       assert.notEqual( $element1.css( 'text-align' ), 'center', 'print style is not applied' );
-
-                                       pending--;
-                                       if ( pending === 0 ) {
-                                               done();
-                                       }
-                               } );
-                       },
-                       {
-                               url: {
-                                       print: [ urlStyleTest( '.mw-test-implement-b1', 'text-align', 'center' ) ],
-                                       screen: [
-                                               // bug 40834: Make sure it actually works with more than 1 stylesheet reference
-                                               urlStyleTest( '.mw-test-implement-b2', 'float', 'left' ),
-                                               urlStyleTest( '.mw-test-implement-b3', 'float', 'right' )
-                                       ]
-                               }
-                       }
-               );
-
-               mw.loader.load( 'test.implement.b' );
-       } );
-
-       // Backwards compatibility
-       QUnit.test( 'mw.loader.implement( styles={ <media>: text } ) (back-compat)', 2, function ( assert ) {
-               var $element = $( '<div class="mw-test-implement-c"></div>' ).appendTo( '#qunit-fixture' );
-
-               assert.notEqual(
-                       $element.css( 'float' ),
-                       'right',
-                       'style is clear'
-               );
-
-               mw.loader.implement(
-                       'test.implement.c',
-                       function () {
-                               assert.equal(
-                                       $element.css( 'float' ),
-                                       'right',
-                                       'style is applied'
-                               );
-                       },
-                       {
-                               all: '.mw-test-implement-c { float: right; }'
-                       }
-               );
-
-               return mw.loader.using( 'test.implement.c' );
-       } );
-
-       // Backwards compatibility
-       QUnit.test( 'mw.loader.implement( styles={ <media>: [url, ..] } ) (back-compat)', 4, function ( assert ) {
-               var $element = $( '<div class="mw-test-implement-d"></div>' ).appendTo( '#qunit-fixture' ),
-                       $element2 = $( '<div class="mw-test-implement-d2"></div>' ).appendTo( '#qunit-fixture' ),
-                       done = assert.async();
-
-               assert.notEqual(
-                       $element.css( 'float' ),
-                       'right',
-                       'style is clear'
-               );
-               assert.notEqual(
-                       $element2.css( 'text-align' ),
-                       'center',
-                       'style is clear'
-               );
-
-               mw.loader.implement(
-                       'test.implement.d',
-                       function () {
-                               assertStyleAsync( assert, $element, 'float', 'right', function () {
-                                       assert.notEqual( $element2.css( 'text-align' ), 'center', 'print style is not applied (bug 40500)' );
-                                       done();
-                               } );
-                       },
-                       {
-                               all: [ urlStyleTest( '.mw-test-implement-d', 'float', 'right' ) ],
-                               print: [ urlStyleTest( '.mw-test-implement-d2', 'text-align', 'center' ) ]
-                       }
-               );
-
-               mw.loader.load( 'test.implement.d' );
-       } );
-
-       // @import (bug 31676)
-       QUnit.test( 'mw.loader.implement( styles has @import )', 7, function ( assert ) {
-               var isJsExecuted, $element,
-                       done = assert.async();
-
-               mw.loader.implement(
-                       'test.implement.import',
-                       function () {
-                               assert.strictEqual( isJsExecuted, undefined, 'script not executed multiple times' );
-                               isJsExecuted = true;
-
-                               assert.equal( mw.loader.getState( 'test.implement.import' ), 'executing', 'module state during implement() script execution' );
-
-                               $element = $( '<div class="mw-test-implement-import">Foo bar</div>' ).appendTo( '#qunit-fixture' );
-
-                               assert.equal( mw.msg( 'test-foobar' ), 'Hello Foobar, $1!', 'messages load before script execution' );
-
-                               assertStyleAsync( assert, $element, 'float', 'right', function () {
-                                       assert.equal( $element.css( 'text-align' ), 'center',
-                                               'CSS styles after the @import rule are working'
-                                       );
-
-                                       done();
-                               } );
-                       },
-                       {
-                               css: [
-                                       '@import url(\''
-                                               + urlStyleTest( '.mw-test-implement-import', 'float', 'right' )
-                                               + '\');\n'
-                                               + '.mw-test-implement-import { text-align: center; }'
-                               ]
-                       },
-                       {
-                               'test-foobar': 'Hello Foobar, $1!'
-                       }
-               );
-
-               mw.loader.using( 'test.implement.import' ).always( function () {
-                       assert.strictEqual( isJsExecuted, true, 'script executed' );
-                       assert.equal( mw.loader.getState( 'test.implement.import' ), 'ready', 'module state after script execution' );
-               } );
-       } );
-
-       QUnit.test( 'mw.loader.implement( dependency with styles )', 4, function ( assert ) {
-               var $element = $( '<div class="mw-test-implement-e"></div>' ).appendTo( '#qunit-fixture' ),
-                       $element2 = $( '<div class="mw-test-implement-e2"></div>' ).appendTo( '#qunit-fixture' );
-
-               assert.notEqual(
-                       $element.css( 'float' ),
-                       'right',
-                       'style is clear'
-               );
-               assert.notEqual(
-                       $element2.css( 'float' ),
-                       'left',
-                       'style is clear'
-               );
-
-               mw.loader.register( [
-                       [ 'test.implement.e', '0', [ 'test.implement.e2' ] ],
-                       [ 'test.implement.e2', '0' ]
-               ] );
-
-               mw.loader.implement(
-                       'test.implement.e',
-                       function () {
-                               assert.equal(
-                                       $element.css( 'float' ),
-                                       'right',
-                                       'Depending module\'s style is applied'
-                               );
-                       },
-                       {
-                               all: '.mw-test-implement-e { float: right; }'
-                       }
-               );
-
-               mw.loader.implement(
-                       'test.implement.e2',
-                       function () {
-                               assert.equal(
-                                       $element2.css( 'float' ),
-                                       'left',
-                                       'Dependency\'s style is applied'
-                               );
-                       },
-                       {
-                               all: '.mw-test-implement-e2 { float: left; }'
-                       }
-               );
-
-               return mw.loader.using( 'test.implement.e' );
-       } );
-
-       QUnit.test( 'mw.loader.implement( only scripts )', 1, function ( assert ) {
-               mw.loader.implement( 'test.onlyscripts', function () {} );
-               assert.strictEqual( mw.loader.getState( 'test.onlyscripts' ), 'ready' );
-       } );
-
-       QUnit.test( 'mw.loader.implement( only messages )', 2, function ( assert ) {
-               assert.assertFalse( mw.messages.exists( 'bug_29107' ), 'Verify that the test message doesn\'t exist yet' );
-
-               // jscs: disable requireCamelCaseOrUpperCaseIdentifiers
-               mw.loader.implement( 'test.implement.msgs', [], {}, { bug_29107: 'loaded' } );
-               // jscs: enable requireCamelCaseOrUpperCaseIdentifiers
-
-               return mw.loader.using( 'test.implement.msgs', function () {
-                       assert.ok( mw.messages.exists( 'bug_29107' ), 'Bug 29107: messages-only module should implement ok' );
-               }, function () {
-                       assert.ok( false, 'Error callback fired while implementing "test.implement.msgs" module' );
-               } );
-       } );
-
-       QUnit.test( 'mw.loader.implement( empty )', 1, function ( assert ) {
-               mw.loader.implement( 'test.empty' );
-               assert.strictEqual( mw.loader.getState( 'test.empty' ), 'ready' );
-       } );
-
-       QUnit.test( 'mw.loader with broken indirect dependency', 4, function ( assert ) {
-               // don't emit an error event
-               this.sandbox.stub( mw, 'track' );
-
-               mw.loader.register( [
-                       [ 'test.module1', '0' ],
-                       [ 'test.module2', '0', [ 'test.module1' ] ],
-                       [ 'test.module3', '0', [ 'test.module2' ] ]
-               ] );
-               mw.loader.implement( 'test.module1', function () {
-                       throw new Error( 'expected' );
-               }, {}, {} );
-               assert.strictEqual( mw.loader.getState( 'test.module1' ), 'error', 'Expected "error" state for test.module1' );
-               assert.strictEqual( mw.loader.getState( 'test.module2' ), 'error', 'Expected "error" state for test.module2' );
-               assert.strictEqual( mw.loader.getState( 'test.module3' ), 'error', 'Expected "error" state for test.module3' );
-
-               assert.strictEqual( mw.track.callCount, 1 );
-       } );
-
-       QUnit.test( 'mw.loader with circular dependency', 1, function ( assert ) {
-               mw.loader.register( [
-                       [ 'test.circle1', '0', [ 'test.circle2' ] ],
-                       [ 'test.circle2', '0', [ 'test.circle3' ] ],
-                       [ 'test.circle3', '0', [ 'test.circle1' ] ]
-               ] );
-               assert.throws( function () {
-                       mw.loader.using( 'test.circle3' );
-               }, /Circular/, 'Detect circular dependency' );
-       } );
-
-       QUnit.test( 'mw.loader out-of-order implementation', 9, function ( assert ) {
-               mw.loader.register( [
-                       [ 'test.module4', '0' ],
-                       [ 'test.module5', '0', [ 'test.module4' ] ],
-                       [ 'test.module6', '0', [ 'test.module5' ] ]
-               ] );
-               mw.loader.implement( 'test.module4', function () {} );
-               assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' );
-               assert.strictEqual( mw.loader.getState( 'test.module5' ), 'registered', 'Expected "registered" state for test.module5' );
-               assert.strictEqual( mw.loader.getState( 'test.module6' ), 'registered', 'Expected "registered" state for test.module6' );
-               mw.loader.implement( 'test.module6', function () {} );
-               assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' );
-               assert.strictEqual( mw.loader.getState( 'test.module5' ), 'registered', 'Expected "registered" state for test.module5' );
-               assert.strictEqual( mw.loader.getState( 'test.module6' ), 'loaded', 'Expected "loaded" state for test.module6' );
-               mw.loader.implement( 'test.module5', function () {} );
-               assert.strictEqual( mw.loader.getState( 'test.module4' ), 'ready', 'Expected "ready" state for test.module4' );
-               assert.strictEqual( mw.loader.getState( 'test.module5' ), 'ready', 'Expected "ready" state for test.module5' );
-               assert.strictEqual( mw.loader.getState( 'test.module6' ), 'ready', 'Expected "ready" state for test.module6' );
-       } );
-
-       QUnit.test( 'mw.loader missing dependency', 13, function ( assert ) {
-               mw.loader.register( [
-                       [ 'test.module7', '0' ],
-                       [ 'test.module8', '0', [ 'test.module7' ] ],
-                       [ 'test.module9', '0', [ 'test.module8' ] ]
-               ] );
-               mw.loader.implement( 'test.module8', function () {} );
-               assert.strictEqual( mw.loader.getState( 'test.module7' ), 'registered', 'Expected "registered" state for test.module7' );
-               assert.strictEqual( mw.loader.getState( 'test.module8' ), 'loaded', 'Expected "loaded" state for test.module8' );
-               assert.strictEqual( mw.loader.getState( 'test.module9' ), 'registered', 'Expected "registered" state for test.module9' );
-               mw.loader.state( 'test.module7', 'missing' );
-               assert.strictEqual( mw.loader.getState( 'test.module7' ), 'missing', 'Expected "missing" state for test.module7' );
-               assert.strictEqual( mw.loader.getState( 'test.module8' ), 'error', 'Expected "error" state for test.module8' );
-               assert.strictEqual( mw.loader.getState( 'test.module9' ), 'error', 'Expected "error" state for test.module9' );
-               mw.loader.implement( 'test.module9', function () {} );
-               assert.strictEqual( mw.loader.getState( 'test.module7' ), 'missing', 'Expected "missing" state for test.module7' );
-               assert.strictEqual( mw.loader.getState( 'test.module8' ), 'error', 'Expected "error" state for test.module8' );
-               assert.strictEqual( mw.loader.getState( 'test.module9' ), 'error', 'Expected "error" state for test.module9' );
-               mw.loader.using(
-                       [ 'test.module7' ],
-                       function () {
-                               assert.ok( false, 'Success fired despite missing dependency' );
-                               assert.ok( true, 'QUnit expected() count dummy' );
-                       },
-                       function ( e, dependencies ) {
-                               assert.strictEqual( $.isArray( dependencies ), true, 'Expected array of dependencies' );
-                               assert.deepEqual( dependencies, [ 'test.module7' ], 'Error callback called with module test.module7' );
-                       }
-               );
-               mw.loader.using(
-                       [ 'test.module9' ],
-                       function () {
-                               assert.ok( false, 'Success fired despite missing dependency' );
-                               assert.ok( true, 'QUnit expected() count dummy' );
-                       },
-                       function ( e, dependencies ) {
-                               assert.strictEqual( $.isArray( dependencies ), true, 'Expected array of dependencies' );
-                               dependencies.sort();
-                               assert.deepEqual(
-                                       dependencies,
-                                       [ 'test.module7', 'test.module8', 'test.module9' ],
-                                       'Error callback called with all three modules as dependencies'
-                               );
-                       }
-               );
-       } );
-
-       QUnit.test( 'mw.loader dependency handling', 5, function ( assert ) {
-               var done = assert.async();
-               mw.loader.register( [
-                       // [module, version, dependencies, group, source]
-                       [ 'testMissing', '1', [], null, 'testloader' ],
-                       [ 'testUsesMissing', '1', [ 'testMissing' ], null, 'testloader' ],
-                       [ 'testUsesNestedMissing', '1', [ 'testUsesMissing' ], null, 'testloader' ]
-               ] );
-
-               function verifyModuleStates() {
-                       assert.equal( mw.loader.getState( 'testMissing' ), 'missing', 'Module not known to server must have state "missing"' );
-                       assert.equal( mw.loader.getState( 'testUsesMissing' ), 'error', 'Module with missing dependency must have state "error"' );
-                       assert.equal( mw.loader.getState( 'testUsesNestedMissing' ), 'error', 'Module with indirect missing dependency must have state "error"' );
-               }
-
-               mw.loader.using( [ 'testUsesNestedMissing' ],
-                       function () {
-                               assert.ok( false, 'Error handler should be invoked.' );
-                               assert.ok( true ); // Dummy to reach QUnit expect()
-
-                               verifyModuleStates();
-
-                               done();
-                       },
-                       function ( e, badmodules ) {
-                               assert.ok( true, 'Error handler should be invoked.' );
-                               // As soon as server spits out state('testMissing', 'missing');
-                               // it will bubble up and trigger the error callback.
-                               // Therefor the badmodules array is not testUsesMissing or testUsesNestedMissing.
-                               assert.deepEqual( badmodules, [ 'testMissing' ], 'Bad modules as expected.' );
-
-                               verifyModuleStates();
-
-                               done();
-                       }
-               );
-       } );
-
-       QUnit.test( 'mw.loader skin-function handling', 5, function ( assert ) {
-               mw.loader.register( [
-                       // [module, version, dependencies, group, source, skip]
-                       [ 'testSkipped', '1', [], null, 'testloader', 'return true;' ],
-                       [ 'testNotSkipped', '1', [], null, 'testloader', 'return false;' ],
-                       [ 'testUsesSkippable', '1', [ 'testSkipped', 'testNotSkipped' ], null, 'testloader' ]
-               ] );
-
-               function verifyModuleStates() {
-                       assert.equal( mw.loader.getState( 'testSkipped' ), 'ready', 'Module is ready when skipped' );
-                       assert.equal( mw.loader.getState( 'testNotSkipped' ), 'ready', 'Module is ready when not skipped but loaded' );
-                       assert.equal( mw.loader.getState( 'testUsesSkippable' ), 'ready', 'Module is ready when skippable dependencies are ready' );
-               }
-
-               return mw.loader.using( [ 'testUsesSkippable' ],
-                       function () {
-                               assert.ok( true, 'Success handler should be invoked.' );
-                               assert.ok( true ); // Dummy to match error handler and reach QUnit expect()
-
-                               verifyModuleStates();
-                       },
-                       function ( e, badmodules ) {
-                               assert.ok( false, 'Error handler should not be invoked.' );
-                               assert.deepEqual( badmodules, [], 'Bad modules as expected.' );
-
-                               verifyModuleStates();
-                       }
-               );
-       } );
-
-       QUnit.asyncTest( 'mw.loader( "//protocol-relative" ) (bug 30825)', 2, function ( assert ) {
-               // This bug was actually already fixed in 1.18 and later when discovered in 1.17.
-               // Test is for regressions!
-
-               // Forge a URL to the test callback script
-               var target = QUnit.fixurl(
-                       mw.config.get( 'wgServer' ) + mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/qunitOkCall.js'
-               );
-
-               // Confirm that mw.loader.load() works with protocol-relative URLs
-               target = target.replace( /https?:/, '' );
-
-               assert.equal( target.slice( 0, 2 ), '//',
-                       'URL must be relative to test relative URLs!'
-               );
-
-               // Async!
-               // The target calls QUnit.start
-               mw.loader.load( target );
-       } );
-
-       QUnit.asyncTest( 'mw.loader( "/absolute-path" )', 2, function ( assert ) {
-               // Forge a URL to the test callback script
-               var target = QUnit.fixurl(
-                       mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/qunitOkCall.js'
-               );
-
-               // Confirm that mw.loader.load() works with absolute-paths (relative to current hostname)
-               assert.equal( target.slice( 0, 1 ), '/', 'URL is relative to document root' );
-
-               // Async!
-               // The target calls QUnit.start
-               mw.loader.load( target );
-       } );
-
-       QUnit.test( 'mw.loader() executing race (T112232)', 2, function ( assert ) {
-               var done = false;
-
-               // The red herring schedules its CSS buffer first. In T112232, a bug in the
-               // state machine would cause the job for testRaceLoadMe to run with an earlier job.
-               mw.loader.implement(
-                       'testRaceRedHerring',
-                       function () {},
-                       { css: [ '.mw-testRaceRedHerring {}' ] }
-               );
-               mw.loader.implement(
-                       'testRaceLoadMe',
-                       function () {
-                               done = true;
-                       },
-                       { css: [ '.mw-testRaceLoadMe { float: left; }' ] }
-               );
-
-               mw.loader.load( [ 'testRaceRedHerring', 'testRaceLoadMe' ] );
-               return mw.loader.using( 'testRaceLoadMe', function () {
-                       assert.strictEqual( done, true, 'script ran' );
-                       assert.strictEqual( mw.loader.getState( 'testRaceLoadMe' ), 'ready', 'state' );
-               } );
-       } );
-
        QUnit.test( 'mw.hook', 13, function ( assert ) {
                var hook, add, fire, chars, callback;
 
                );
        } );
 
-       QUnit.test( 'mw.loader require()', 6, function ( assert ) {
-               mw.loader.register( [
-                       [ 'test.require1', '0' ],
-                       [ 'test.require2', '0' ],
-                       [ 'test.require3', '0' ],
-                       [ 'test.require4', '0', [ 'test.require3' ] ]
-               ] );
-               mw.loader.implement( 'test.require1', function () {} );
-               mw.loader.implement( 'test.require2', function ( $, jQuery, require, module ) {
-                       module.exports = 1;
-               } );
-               mw.loader.implement( 'test.require3', function ( $, jQuery, require, module ) {
-                       module.exports = function () {
-                               return 'hello world';
-                       };
-               } );
-               mw.loader.implement( 'test.require4', function ( $, jQuery, require, module ) {
-                       var other = require( 'test.require3' );
-                       module.exports = {
-                               pizza: function () {
-                                       return other();
-                               }
-                       };
-               } );
-               return mw.loader.using( [ 'test.require1', 'test.require2', 'test.require3', 'test.require4' ] )
-               .then( function ( require ) {
-                       var module1, module2, module3, module4;
-
-                       module1 = require( 'test.require1' );
-                       module2 = require( 'test.require2' );
-                       module3 = require( 'test.require3' );
-                       module4 = require( 'test.require4' );
-
-                       assert.strictEqual( typeof module1, 'object', 'export of module with no export' );
-                       assert.strictEqual( module2, 1, 'export a number' );
-                       assert.strictEqual( module3(), 'hello world', 'export a function' );
-                       assert.strictEqual( typeof module4.pizza, 'function', 'export an object' );
-                       assert.strictEqual( module4.pizza(), 'hello world', 'module can require other modules' );
-
-                       assert.throws( function () {
-                               require( '_badmodule' );
-                       }, /is not loaded/, 'Requesting non-existent modules throws error.' );
-               } );
-       } );
-
-       QUnit.test( 'mw.loader require() in debug mode', 1, function ( assert ) {
-               var path = mw.config.get( 'wgScriptPath' );
-               mw.loader.register( [
-                       [ 'test.require.define', '0' ],
-                       [ 'test.require.callback', '0', [ 'test.require.define' ] ]
-               ] );
-               mw.loader.implement( 'test.require.callback', [ QUnit.fixurl( path + '/tests/qunit/data/requireCallMwLoaderTestCallback.js' ) ] );
-               mw.loader.implement( 'test.require.define', [ QUnit.fixurl( path + '/tests/qunit/data/defineCallMwLoaderTestCallback.js' ) ] );
-
-               return mw.loader.using( 'test.require.callback' ).then( function ( require ) {
-                       var exported = require( 'test.require.callback' );
-                       assert.strictEqual( exported, 'Require worked.Define worked.',
-                               'module.exports worked in debug mode' );
-               }, function () {
-                       assert.ok( false, 'Error callback fired while loader.using "test.require.callback" module' );
-               } );
-       } );
-
-}( mediaWiki, jQuery ) );
+}( mediaWiki ) );