From: jenkins-bot Date: Fri, 19 Aug 2016 19:39:17 +0000 (+0000) Subject: Merge "Allow requiring cache size for page props" X-Git-Tag: 1.31.0-rc.0~5988 X-Git-Url: http://git.cyclocoop.org/%7B%24admin_url%7Dmes_infos.php?a=commitdiff_plain;h=ded03d70579e8877f2144f0c24a1432d99ecaddd;hp=5e2376eb94f8d043283e8acc3c74ae93fba1da44;p=lhc%2Fweb%2Fwiklou.git Merge "Allow requiring cache size for page props" --- diff --git a/RELEASE-NOTES-1.28 b/RELEASE-NOTES-1.28 index f6c3530316..eac4e2c32f 100644 --- a/RELEASE-NOTES-1.28 +++ b/RELEASE-NOTES-1.28 @@ -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 diff --git a/autoload.php b/autoload.php index fc6577ef14..e37a011093 100644 --- a/autoload.php +++ b/autoload.php @@ -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', diff --git a/includes/OutputPage.php b/includes/OutputPage.php index f5a37d460a..eb3040cd28 100644 --- a/includes/OutputPage.php +++ b/includes/OutputPage.php @@ -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 + $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 ``. 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 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' ); } /** diff --git a/includes/api/ApiAuthManagerHelper.php b/includes/api/ApiAuthManagerHelper.php index c970f932d7..a4f54ee6d8 100644 --- a/includes/api/ApiAuthManagerHelper.php +++ b/includes/api/ApiAuthManagerHelper.php @@ -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; } diff --git a/includes/api/ApiBase.php b/includes/api/ApiBase.php index 4a1a520f12..fcb748c202 100644 --- a/includes/api/ApiBase.php +++ b/includes/api/ApiBase.php @@ -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() ) ) . '"' . diff --git a/includes/api/ApiLogin.php b/includes/api/ApiLogin.php index 28937f7873..9bc0b3a433 100644 --- a/includes/api/ApiLogin.php +++ b/includes/api/ApiLogin.php @@ -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 = []; diff --git a/includes/api/ApiMain.php b/includes/api/ApiMain.php index 565e829f79..22b079dee1 100644 --- a/includes/api/ApiMain.php +++ b/includes/api/ApiMain.php @@ -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' ); diff --git a/includes/api/ApiQueryBacklinksprop.php b/includes/api/ApiQueryBacklinksprop.php index 3810e90f13..236fb9e06c 100644 --- a/includes/api/ApiQueryBacklinksprop.php +++ b/includes/api/ApiQueryBacklinksprop.php @@ -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__ ); diff --git a/includes/api/ApiUpload.php b/includes/api/ApiUpload.php index fc2fd59056..ac817ba3c9 100644 --- a/includes/api/ApiUpload.php +++ b/includes/api/ApiUpload.php @@ -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, diff --git a/includes/api/i18n/fr.json b/includes/api/i18n/fr.json index 80eec50d93..4b42964c27 100644 --- a/includes/api/i18n/fr.json +++ b/includes/api/i18n/fr.json @@ -25,7 +25,8 @@ "Lbayle", "Verdy p", "Yasten", - "Trial" + "Trial", + "Pols12" ] }, "apihelp-main-description": "
\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
\nÉtat : 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\nRequêtes erronées : 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\nTest : Pour faciliter le test des requêtes de l’API, voyez [[Special:ApiSandbox]].", @@ -1269,7 +1270,7 @@ "apihelp-stashedit-param-section": "Numéro de section. 0 pour la section du haut, new 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.", diff --git a/includes/api/i18n/zh-hans.json b/includes/api/i18n/zh-hans.json index 037aff4526..860b7a771f 100644 --- a/includes/api/i18n/zh-hans.json +++ b/includes/api/i18n/zh-hans.json @@ -1027,7 +1027,7 @@ "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.", @@ -1040,7 +1040,7 @@ "apihelp-query+search-param-enablerewrites": "启用内部查询重写。一些搜索后端可以重写查询到它认为会给出更好结果的地方,例如纠正拼写错误。", "apihelp-query+search-example-simple": "搜索meaning。", "apihelp-query+search-example-text": "搜索文本meaning。", - "apihelp-query+search-example-generator": "获得有关搜索meaning返回页面的页面信息。", + "apihelp-query+search-example-generator": "获取有关搜索meaning返回页面的页面信息。", "apihelp-query+siteinfo-description": "返回有关网站的一般信息。", "apihelp-query+siteinfo-param-prop": "要获取的信息:", "apihelp-query+siteinfo-paramvalue-prop-general": "全部系统信息。", diff --git a/includes/auth/AuthManager.php b/includes/auth/AuthManager.php index b8c536ebd1..acdc01bfea 100644 --- a/includes/auth/AuthManager.php +++ b/includes/auth/AuthManager.php @@ -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 ) { diff --git a/includes/auth/AuthenticationRequest.php b/includes/auth/AuthenticationRequest.php index f6f949ec7c..2474b8b830 100644 --- a/includes/auth/AuthenticationRequest.php +++ b/includes/auth/AuthenticationRequest.php @@ -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. diff --git a/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php b/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php index d32640ea1d..a82f018d48 100644 --- a/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php +++ b/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php @@ -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(); diff --git a/includes/auth/PasswordAuthenticationRequest.php b/includes/auth/PasswordAuthenticationRequest.php index 187c29ae9f..8550f3e211 100644 --- a/includes/auth/PasswordAuthenticationRequest.php +++ b/includes/auth/PasswordAuthenticationRequest.php @@ -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, ]; } diff --git a/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php b/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php index 46cbab5a3a..ed94c1ad2b 100644 --- a/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php +++ b/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php @@ -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; diff --git a/includes/content/ContentHandler.php b/includes/content/ContentHandler.php index 7184980ede..41fdef5709 100644 --- a/includes/content/ContentHandler.php +++ b/includes/content/ContentHandler.php @@ -1,4 +1,7 @@ $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; } /** diff --git a/includes/content/WikiTextStructure.php b/includes/content/WikiTextStructure.php index e83c213c0e..9768d3666e 100644 --- a/includes/content/WikiTextStructure.php +++ b/includes/content/WikiTextStructure.php @@ -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[] diff --git a/includes/content/WikitextContentHandler.php b/includes/content/WikitextContentHandler.php index 9baf64331d..1c46d28806 100644 --- a/includes/content/WikitextContentHandler.php +++ b/includes/content/WikitextContentHandler.php @@ -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 diff --git a/includes/db/DBConnRef.php b/includes/db/DBConnRef.php index 53862b96da..4a7836355a 100644 --- a/includes/db/DBConnRef.php +++ b/includes/db/DBConnRef.php @@ -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() ); } diff --git a/includes/db/Database.php b/includes/db/Database.php index 78975fff17..933bea60a9 100644 --- a/includes/db/Database.php +++ b/includes/db/Database.php @@ -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 ); diff --git a/includes/db/DatabasePostgres.php b/includes/db/DatabasePostgres.php index 867aeb8705..1ecdd26088 100644 --- a/includes/db/DatabasePostgres.php +++ b/includes/db/DatabasePostgres.php @@ -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; diff --git a/includes/db/IDatabase.php b/includes/db/IDatabase.php index af024b8517..bdab09e7b5 100644 --- a/includes/db/IDatabase.php +++ b/includes/db/IDatabase.php @@ -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. * diff --git a/includes/htmlform/fields/HTMLFormFieldCloner.php b/includes/htmlform/fields/HTMLFormFieldCloner.php index f39f54be1a..5d8f491881 100644 --- a/includes/htmlform/fields/HTMLFormFieldCloner.php +++ b/includes/htmlform/fields/HTMLFormFieldCloner.php @@ -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(); diff --git a/includes/jobqueue/jobs/AssembleUploadChunksJob.php b/includes/jobqueue/jobs/AssembleUploadChunksJob.php index 1e804c4575..060cabb627 100644 --- a/includes/jobqueue/jobs/AssembleUploadChunksJob.php +++ b/includes/jobqueue/jobs/AssembleUploadChunksJob.php @@ -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 ) { diff --git a/includes/libs/objectcache/EmptyBagOStuff.php b/includes/libs/objectcache/EmptyBagOStuff.php index 408212a1b3..3f66c06c9f 100644 --- a/includes/libs/objectcache/EmptyBagOStuff.php +++ b/includes/libs/objectcache/EmptyBagOStuff.php @@ -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; } diff --git a/includes/libs/objectcache/WANObjectCache.php b/includes/libs/objectcache/WANObjectCache.php index 143042cfde..c40c819861 100644 --- a/includes/libs/objectcache/WANObjectCache.php +++ b/includes/libs/objectcache/WANObjectCache.php @@ -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; diff --git a/includes/resourceloader/ResourceLoaderImageModule.php b/includes/resourceloader/ResourceLoaderImageModule.php index 3b3bdf7e11..43327c91cc 100644 --- a/includes/resourceloader/ResourceLoaderImageModule.php +++ b/includes/resourceloader/ResourceLoaderImageModule.php @@ -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 index 0000000000..a2a6760192 --- /dev/null +++ b/includes/search/DummySearchIndexFieldDefinition.php @@ -0,0 +1,30 @@ + $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 index 0000000000..df653f1240 --- /dev/null +++ b/includes/search/ParserOutputSearchDataExtractor.php @@ -0,0 +1,92 @@ +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; + } + +} diff --git a/includes/search/SearchIndexFieldDefinition.php b/includes/search/SearchIndexFieldDefinition.php index 3a86c82d0d..8a06b65ed7 100644 --- a/includes/search/SearchIndexFieldDefinition.php +++ b/includes/search/SearchIndexFieldDefinition.php @@ -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 ); + } diff --git a/includes/specials/SpecialSearch.php b/includes/specials/SpecialSearch.php index 9690d45bc9..26b86f9762 100644 --- a/includes/specials/SpecialSearch.php +++ b/includes/specials/SpecialSearch.php @@ -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(); diff --git a/includes/upload/UploadBase.php b/includes/upload/UploadBase.php index ae16f70227..e2f7763251 100644 --- a/includes/upload/UploadBase.php +++ b/includes/upload/UploadBase.php @@ -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; diff --git a/includes/upload/UploadFromChunks.php b/includes/upload/UploadFromChunks.php index 6368db82da..9145a854f3 100644 --- a/includes/upload/UploadFromChunks.php +++ b/includes/upload/UploadFromChunks.php @@ -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; diff --git a/includes/upload/UploadFromStash.php b/includes/upload/UploadFromStash.php index 50bcbc4260..1fbdb7d86d 100644 --- a/includes/upload/UploadFromStash.php +++ b/includes/upload/UploadFromStash.php @@ -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 diff --git a/languages/Language.php b/languages/Language.php index d96710a626..8b39d7734a 100644 --- a/languages/Language.php +++ b/languages/Language.php @@ -25,11 +25,6 @@ * @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; /** diff --git a/languages/i18n/bn.json b/languages/i18n/bn.json index 4921d09ca5..aa81ac2b05 100644 --- a/languages/i18n/bn.json +++ b/languages/i18n/bn.json @@ -1185,6 +1185,7 @@ "right-changetags": "নির্দিষ্ট সংস্করণ এবং দীর্ঘ সম্পাদনাগুলোতে [[Special:Tags|ট্যাগ]] সংযোজন ও অপসারণ করুন", "right-deletechangetags": "ডাটাবেজ থেকে [[Special:Tags|ট্যাগ]] অপসারণ করা", "grant-group-email": "ইমেইল পাঠান", + "grant-group-private-information": "আপনার সম্পর্কিত ব্যক্তিগত তথ্যে প্রবেশাধিকার পায়", "grant-group-other": "বিবিধ কার্যকলাপ", "grant-createaccount": "অ্যাকাউন্ট তৈরি করুন", "grant-createeditmovepage": "পাতা তৈরি, সম্পাদনা এবং স্থানান্তর করুন", @@ -1192,6 +1193,7 @@ "grant-editmyoptions": "আপনার ব্যবহারকারী পছন্দসমূহ সম্পাদনা করুন", "grant-editmywatchlist": "আপনার নজরতালিকা সম্পাদনা করুন", "grant-editprotected": "সংরক্ষিত পাতা সম্পাদনা করুন", + "grant-privateinfo": "ব্যক্তিগত তথ্যে প্রবেশাধিকার", "grant-sendemail": "অন্য ব্যবহারকারীকে ইমেইল পাঠান", "grant-uploadfile": "নতুন ফাইল আপলোড করুন", "grant-basic": "মৌলিক অধিকার", diff --git a/languages/i18n/fr.json b/languages/i18n/fr.json index 13cbfb743c..f145ca9c3a 100644 --- a/languages/i18n/fr.json +++ b/languages/i18n/fr.json @@ -1723,7 +1723,7 @@ "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.", @@ -1751,15 +1751,15 @@ "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": "$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-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. image/jpeg.", @@ -1777,7 +1777,7 @@ "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", diff --git a/languages/i18n/got.json b/languages/i18n/got.json index 090585a7cf..bda094adea 100644 --- a/languages/i18n/got.json +++ b/languages/i18n/got.json @@ -182,7 +182,7 @@ "mainpage-nstab": "𐌰𐌽𐌰𐍃𐍄𐍉𐌳𐌴𐌹𐌽𐌹𐌻𐌰𐌿𐍆𐍃", "error": "𐌰𐌹𐍂𐌶𐌴𐌹", "databaseerror-error": "𐌰𐌹𐍂𐌶𐌴𐌹: $1", - "missing-article": "𐍃𐌰 𐌳𐌰𐍄𐌰𐌱𐌿𐍃 𐌽𐌹 𐌲𐌰𐌽𐌰𐌼 𐌸𐌰𐌽𐌰 𐌱𐍉𐌺𐌰𐍅𐌰𐌿𐍂𐌳𐌰𐌽 𐌴𐌹 𐌹𐍄𐌰 𐍃𐌺𐌰𐌻 𐌱𐌹𐌲𐌹𐍄𐌰𐌽: \"$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": "𐌳𐌰𐍄𐌰𐌱𐌴𐍃 𐌽𐌹 𐌱𐌹𐌲𐌰𐍄 𐌱𐍉𐌺𐍉𐍃 𐌻𐌰𐌿𐌱𐌹𐍃 𐌸𐌹𐌶𐌴𐌹 𐍃𐌺𐌿𐌻𐌳𐌴𐌳𐌹 𐌱𐌹𐌲𐌹𐍄𐌰𐌽, 𐌷𐌰𐌹𐍄𐌰𐌽𐍃 \"$1\" $2. \n\n𐌸𐌰𐍄𐌰 𐌿𐍆𐍄𐌰 𐍅𐌰𐌹𐍂𐌸𐌹𐌸 𐌾𐌰𐌱𐌰𐌹 𐌻𐌰𐌹𐍃𐍄𐌾𐌰𐌳𐌰 𐍆𐌰𐌹𐍂𐌽𐌾𐌰 𐌳𐌹𐍆𐍆 𐌸𐌰𐌿 𐍃𐍀𐌹𐌻𐌻𐌰𐌲𐌰𐍅𐌹𐍃𐍃 𐍃𐌴𐌹 𐍆𐍂𐌰𐌵𐌹𐍃𐍄𐌹𐌳𐌰 𐌹𐍃𐍄. 𐌽𐌹𐌱𐌰𐌹 𐌹𐍃𐍄, 𐌼𐌰𐌷𐍄𐍃 𐌹𐍃𐍄 𐌴𐌹 𐌱𐌹𐌲𐌴𐍄𐌴𐌹𐍃 𐌰𐌹𐍂𐌶𐌴𐌹𐌽 𐌹𐌽 𐍃𐌰𐌿𐍆𐍄𐍅𐌰𐌹𐍂𐌰. \n\n𐌱𐌹𐌳𐌾𐌰𐌼 𐌸𐌿𐌺, 𐌼𐌴𐍂𐌴𐌹 𐌸𐌰𐍄𐌰 𐌳𐌿 [[Special:ListUsers/sysop\n|𐍂𐌴𐌹𐌺]] 𐌲𐌹𐍆𐌿𐌷 𐌲𐌰𐍅𐌹𐍃𐍃.", "badtitle": "𐌿𐌽𐍂𐌰𐌹𐌷𐍄𐌰𐍄𐌰 𐌿𐍆𐌰𐍂𐌼𐌴𐌻𐌹", "badtitletext": "𐍆𐍂𐌰𐌹𐌷𐌰𐌽𐍃 𐌻𐌰𐌿𐍆𐍃 𐍅𐌰𐍃 𐌿𐌽𐌲𐌰𐌼𐌰𐌲𐌰𐌽𐌳𐍃, 𐌻𐌰𐌿𐍃, 𐌰𐌹𐌸𐌸𐌰𐌿 𐌿𐌽𐍂𐌰𐌹𐌷𐍄𐌰𐌱𐌰 𐌲𐌰𐍅𐌹𐌳𐌰𐌽𐍃 𐌼𐌹𐌸𐍂𐌰𐌶𐌳𐌰 𐌸𐌰𐌿 𐌼𐌹𐌸-𐍅𐌹𐌺𐌹 𐌿𐍆𐌰𐍂𐌼𐌴𐌻𐌹. 𐌼𐌰𐌲𐌹 𐌷𐌰𐌱𐌰𐌽 𐌰𐌹𐌽𐌰 𐌸𐌰𐌿 𐌼𐌰𐌽𐌰𐌲𐌹𐌶𐍉𐍃 𐌱𐍉𐌺𐍉𐍃 𐌱𐍂𐌿𐌺𐌹𐌳𐍉𐍃 𐌹𐌽 𐌿𐍆𐌰𐍂𐌼𐌴𐌻𐌾𐌰𐌼.", "viewsource": "𐍃𐌰𐌹𐍈 𐌱𐍂𐌿𐌽𐌽𐌰𐌽", diff --git a/languages/i18n/nap.json b/languages/i18n/nap.json index c756f47e2e..b13f5f9189 100644 --- a/languages/i18n/nap.json +++ b/languages/i18n/nap.json @@ -1266,6 +1266,7 @@ "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", @@ -1510,6 +1511,7 @@ "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", @@ -1925,9 +1927,11 @@ "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", diff --git a/resources/ResourcesOOUI.php b/resources/ResourcesOOUI.php index b31fe829f8..c3a287dc8d 100644 --- a/resources/ResourcesOOUI.php +++ b/resources/ResourcesOOUI.php @@ -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. diff --git a/resources/assets/licenses/README b/resources/assets/licenses/README index 0f5cf513ca..dae2549b14 100644 --- a/resources/assets/licenses/README +++ b/resources/assets/licenses/README @@ -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 index 57c905a345..0000000000 --- a/resources/src/oojs-ui-styles-skip.js +++ /dev/null @@ -1,9 +0,0 @@ -/*! - * Skip function for OOjs UI PHP style modules. - * - * The `` 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; diff --git a/tests/phpunit/includes/auth/AuthenticationRequestTest.php b/tests/phpunit/includes/auth/AuthenticationRequestTest.php index a7df221700..7d2ba8d749 100644 --- a/tests/phpunit/includes/auth/AuthenticationRequestTest.php +++ b/tests/phpunit/includes/auth/AuthenticationRequestTest.php @@ -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; diff --git a/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php b/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php index aa0e3c70f5..b5c8a36cd1 100644 --- a/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php +++ b/tests/phpunit/includes/auth/AuthenticationRequestTestCase.php @@ -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': diff --git a/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php index 18c46f7cc5..c52c3e9f54 100644 --- a/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php +++ b/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php @@ -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( [ diff --git a/tests/phpunit/includes/content/ContentHandlerTest.php b/tests/phpunit/includes/content/ContentHandlerTest.php index bb9050fbd3..39948ca130 100644 --- a/tests/phpunit/includes/content/ContentHandlerTest.php +++ b/tests/phpunit/includes/content/ContentHandlerTest.php @@ -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'; } ); diff --git a/tests/phpunit/includes/content/WikitextStructureTest.php b/tests/phpunit/includes/content/WikitextStructureTest.php index 6d83057b7e..4301fb8c35 100644 --- a/tests/phpunit/includes/content/WikitextStructureTest.php +++ b/tests/phpunit/includes/content/WikitextStructureTest.php @@ -25,61 +25,6 @@ class WikitextStructureTest extends MediaWikiLangTestCase { return new WikiTextStructure( $this->getParserOutput( $text ) ); } - public function testCategories() { - $text = <<getStructure( $text ); - $cats = $struct->categories(); - $this->assertCount( 2, $cats ); - $this->assertContains( "Some Category", $cats ); - $this->assertContains( "Yet another category", $cats ); - } - - public function testOutgoingLinks() { - $text = <<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 = <<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 = <<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!" ); + } } diff --git a/tests/phpunit/includes/libs/ObjectFactoryTest.php b/tests/phpunit/includes/libs/ObjectFactoryTest.php index a4871a64c4..f8dda6f8af 100644 --- a/tests/phpunit/includes/libs/ObjectFactoryTest.php +++ b/tests/phpunit/includes/libs/ObjectFactoryTest.php @@ -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() { diff --git a/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php b/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php index 6a3cd15fcc..35005f5c6a 100644 --- a/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php +++ b/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php @@ -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 index 0000000000..69d0b76f25 --- /dev/null +++ b/tests/phpunit/includes/search/ParserOutputSearchDataExtractorTest.php @@ -0,0 +1,70 @@ + '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 index 0000000000..20e88f5a5b --- /dev/null +++ b/tests/phpunit/specials/SpecialSearchTest.php @@ -0,0 +1,23 @@ +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'] ); + } +} diff --git a/tests/phpunit/structure/ResourcesTest.php b/tests/phpunit/structure/ResourcesTest.php index 6446416109..86ce53f339 100644 --- a/tests/phpunit/structure/ResourcesTest.php +++ b/tests/phpunit/structure/ResourcesTest.php @@ -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. diff --git a/tests/qunit/QUnitTestResources.php b/tests/qunit/QUnitTestResources.php index 95f28c85b9..e30088dbc8 100644 --- a/tests/qunit/QUnitTestResources.php +++ b/tests/qunit/QUnitTestResources.php @@ -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 index 0000000000..41d800a0a7 --- /dev/null +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js @@ -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 = $( '
' ).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": { : [url, ..] } } )', 7, function ( assert ) { + var $element1 = $( '
' ).appendTo( '#qunit-fixture' ), + $element2 = $( '
' ).appendTo( '#qunit-fixture' ), + $element3 = $( '
' ).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={ : text } ) (back-compat)', 2, function ( assert ) { + var $element = $( '
' ).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={ : [url, ..] } ) (back-compat)', 4, function ( assert ) { + var $element = $( '
' ).appendTo( '#qunit-fixture' ), + $element2 = $( '
' ).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 = $( '
Foo bar
' ).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 = $( '
' ).appendTo( '#qunit-fixture' ), + $element2 = $( '
' ).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 ) ); diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.test.js index baec37c9e2..ab463a9720 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.test.js @@ -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' ); @@ -32,11 +32,6 @@ } } ) ); - 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' ); @@ -349,619 +344,6 @@ 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 = $( '
' ).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": { : [url, ..] } } )', 7, function ( assert ) { - var $element1 = $( '
' ).appendTo( '#qunit-fixture' ), - $element2 = $( '
' ).appendTo( '#qunit-fixture' ), - $element3 = $( '
' ).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={ : text } ) (back-compat)', 2, function ( assert ) { - var $element = $( '
' ).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={ : [url, ..] } ) (back-compat)', 4, function ( assert ) { - var $element = $( '
' ).appendTo( '#qunit-fixture' ), - $element2 = $( '
' ).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 = $( '
Foo bar
' ).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 = $( '
' ).appendTo( '#qunit-fixture' ), - $element2 = $( '
' ).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; @@ -1054,67 +436,4 @@ ); } ); - 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 ) );