From: jenkins-bot Date: Fri, 12 Oct 2018 21:48:54 +0000 (+0000) Subject: Merge "Pass LBFactory to WatchedItemStore" X-Git-Tag: 1.34.0-rc.0~3790 X-Git-Url: http://git.cyclocoop.org/%7B%24admin_url%7Dmes_infos.php?a=commitdiff_plain;h=bce87740d7a51b1223d7088d9b21b92e424493af;hp=98c37e154905e5578c8defbdc040dd53679ba115;p=lhc%2Fweb%2Fwiklou.git Merge "Pass LBFactory to WatchedItemStore" --- diff --git a/.mailmap b/.mailmap index f199b65457..8637e77564 100644 --- a/.mailmap +++ b/.mailmap @@ -30,6 +30,7 @@ Aaron Schulz Adam Roses Wight Adam Roses Wight addshore +addshore Aditya Sastry Adrian Heine Alex Z. @@ -121,8 +122,9 @@ Daniel Friesen Daniel Friesen Daniel Friesen Daniel Friesen -Daniel Kinzler -Daniel Kinzler +Daniel Kinzler +Daniel Kinzler +Daniel Kinzler Daniel Renfro Danny B. Danny B. @@ -132,6 +134,8 @@ Darian Anthony Patrick Darkdragon09 David Causse David Chan +Dayllan Maza +Dayllan Maza Dereckson Derk-Jan Hartman Derk-Jan Hartman @@ -240,6 +244,8 @@ Karun Dambiec Katie Filbert Katie Filbert Kevin Israel +Kosta Harlan +Kosta Harlan Kunal Grover Kunal Mehta Kunal Mehta @@ -327,6 +333,7 @@ Patrick Reilly Patrick Reilly Patrick Westerhoff Paul Copperman +Petar Petković Peter Coombe Peter Coti Peter Potrowl diff --git a/RELEASE-NOTES-1.32 b/RELEASE-NOTES-1.32 index 991708e89d..a63a16d293 100644 --- a/RELEASE-NOTES-1.32 +++ b/RELEASE-NOTES-1.32 @@ -293,6 +293,9 @@ because of Phabricator reports. * Another two OutputPage methods, setPageTitleActionText() and getPageTitleActionText(), were removed. They did nothing since 1.15 (almost ten years). Use setHTMLTitle() directly. +* The return value of OutputPage::adaptCdnTTL() has been removed. The + value returned was misleading and probably not what any caller would + have wanted. * All MagicWord static member variables have been removed. Use appropriate hooks or MagicWordFactory methods instead. * MagicWord::clearCache() has been removed. Instead, create a new @@ -314,6 +317,9 @@ because of Phabricator reports. * 'uppercase-se' (NorthernSamiUppercaseCollation) - use 'uca-se' instead * 'xx-uca-et' (CollationEt) - use 'uca-et' instead * 'xx-uca-fa' (CollationFa) - use 'uca-fa' instead +* LanguageCode::bcp47() now always returns a valid BCP 47 code. This means + that some MediaWiki-specific language codes, such as `simple`, are mapped + into valid BCP 47 codes (eg `en-simple`). * The hooks 'SpecialRecentChangesFilters' & 'SpecialWatchlistFilters' deprecated in 1.23 were removed. Instead, use 'ChangesListSpecialPageStructuredFilters'. The ChangesListSpecialPage code for these legacy hooks, and their use in @@ -344,6 +350,14 @@ because of Phabricator reports. removed. Use the 'ChangesListSpecialPageStructuredFilters' hook instead. * DeferredUpdates::setImmediateMode(), deprecated since 1.29, has been removed. * File / MediaHandler::getStreamHeaders(), deprecated since 1.30, was removed. +* The hook 'DoEditSectionLink', deprecated since 1.25, has been removed. Use + the hook 'SkinEditSectionLinks' instead. +* The hook 'UserGetImplicitGroups', deprecated since 1.25, has been removed. +* The global function wfRunHooks, deprecated since 1.25, has now been removed. + Use Hooks::run(). +* The hook 'UnknownAction', deprecated since 1.19, has now been removed. +* The hook 'ParserLimitReport', deprecated since 1.22, has been removed. Use + the hooks 'ParserLimitReportPrepare' and 'ParserLimitReportFormat' instead. === Deprecations in 1.32 === * HTMLForm::setSubmitProgressive() is deprecated. No need to call it. Submit @@ -514,6 +528,12 @@ because of Phabricator reports. as a string. They should be given as a OOUI\FieldLayout object instead. Notably, this affects fields defined in the 'GetPreferences' hook, because Special:Preferences uses an OOUI form now. (If possible, don't use 'rawrow'.) +* In Skin::doEditSectionLink omitting the parameters $tooltip and $lang is + deprecated. For the $lang parameter, types other than Language are + deprecated. +* The $wgUseKeyHeader configuration option and the + OutputPage::getKeyHeader() method have been deprecated; the relevant + draft IETF spec expired without becoming a standard. === Other changes in 1.32 === * (T198811) The following tables have had their UNIQUE indexes turned into diff --git a/docs/hooks.txt b/docs/hooks.txt index a47f68f1a2..fd7b3005d7 100644 --- a/docs/hooks.txt +++ b/docs/hooks.txt @@ -1385,19 +1385,6 @@ an article &$article: article (object) being viewed &$oldid: oldid (int) being viewed -'DoEditSectionLink': DEPRECATED since 1.25! Use SkinEditSectionLinks instead. -Override the HTML generated for section edit links -$skin: Skin object rendering the UI -$title: Title object for the title being linked to (may not be the same as - the page title, if the section is included from a template) -$section: The designation of the section being pointed to, to be included in - the link, like "§ion=$section" -$tooltip: The default tooltip. Escape before using. - By default, this is wrapped in the 'editsectionhint' message. -&$result: The HTML to return, prefilled with the default plus whatever other - changes earlier hooks have made -$lang: The language code to use for the link in the wfMessage function - 'EditFilter': Perform checks on an edit $editor: EditPage instance (object). The edit form (see includes/EditPage.php) $text: Contents of the edit box @@ -2660,13 +2647,6 @@ cache or return false to not use it. &$parser: Parser object &$varCache: variable cache (array) -'ParserLimitReport': DEPRECATED since 1.22! Use ParserLimitReportPrepare and -ParserLimitReportFormat instead. -Called at the end of Parser:parse() when the parser will -include comments about size of the text parsed. -$parser: Parser object -&$limitReport: text that will be included (without comment tags) - 'ParserLimitReportFormat': Called for each row in the parser limit report that needs formatting. If nothing handles this hook, the default is to use "$key" to get the label, and "$key-value" or "$key-value-text"/"$key-value-html" to @@ -3573,12 +3553,6 @@ Since 1.24: Paths pointing to a directory will be recursively scanned for test case files matching the suffix "Test.php". &$paths: list of test cases and directories to search. -'UnknownAction': DEPRECATED since 1.19! To add an action in an extension, -create a subclass of Action, and add a new key to $wgActions. -An unknown "action" has occurred (useful for defining your own actions). -$action: action name -$article: article "acted on" - 'UnwatchArticle': Before a watch is removed from an article. &$user: user watching &$page: WikiPage object to be removed @@ -3737,10 +3711,6 @@ $user: User object &$timestamp: timestamp, change this to override local email authentication timestamp -'UserGetImplicitGroups': DEPRECATED since 1.25! -Called in User::getImplicitGroups(). -&$groups: List of implicit (automatically-assigned) groups - 'UserGetLanguageObject': Called when getting user's interface language object. $user: User object &$code: Language code that will be used to create the object diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 4ed17074d4..6a1ed925da 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -2741,8 +2741,9 @@ $wgUseESI = false; /** * Send the Key HTTP header for better caching. - * See https://datatracker.ietf.org/doc/draft-fielding-http-key/ for details. + * See https://datatracker.ietf.org/doc/draft-ietf-httpbis-key/ for details. * @since 1.27 + * @deprecated in 1.32, the IETF spec expired without becoming a standard. */ $wgUseKeyHeader = false; diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php index 49a2612360..b536e694db 100644 --- a/includes/GlobalFunctions.php +++ b/includes/GlobalFunctions.php @@ -3037,21 +3037,6 @@ function wfGetMessageCacheStorage() { return ObjectCache::getInstance( $wgMessageCacheType ); } -/** - * Call hook functions defined in $wgHooks - * - * @param string $event Event name - * @param array $args Parameters passed to hook functions - * @param string|null $deprecatedVersion Optionally mark hook as deprecated with version number - * - * @return bool True if no handler aborted the hook - * @deprecated since 1.25 - use Hooks::run - */ -function wfRunHooks( $event, array $args = [], $deprecatedVersion = null ) { - wfDeprecated( __METHOD__, '1.25' ); - return Hooks::run( $event, $args, $deprecatedVersion ); -} - /** * Wrapper around php's unpack. * diff --git a/includes/MediaWiki.php b/includes/MediaWiki.php index 4636ba349e..bc576374d6 100644 --- a/includes/MediaWiki.php +++ b/includes/MediaWiki.php @@ -501,18 +501,10 @@ class MediaWiki { $action->show(); return; } - // NOTE: deprecated hook. Add to $wgActions instead - if ( Hooks::run( - 'UnknownAction', - [ - $request->getVal( 'action', 'view' ), - $page - ], - '1.19' - ) ) { - $output->setStatusCode( 404 ); - $output->showErrorPage( 'nosuchaction', 'nosuchactiontext' ); - } + + // If we've not found out which action it is by now, it's unknown + $output->setStatusCode( 404 ); + $output->showErrorPage( 'nosuchaction', 'nosuchactiontext' ); } /** diff --git a/includes/OutputPage.php b/includes/OutputPage.php index b1874b9106..cde92e8c17 100644 --- a/includes/OutputPage.php +++ b/includes/OutputPage.php @@ -323,6 +323,11 @@ class OutputPage extends ContextSource { */ private $CSPNonce; + /** + * @var array A cache of the names of the cookies that will influence the cache + */ + private static $cacheVaryCookies = null; + /** * Constructor for OutputPage. This should not be called directly. * Instead a new RequestContext should be created and it will implicitly create @@ -2100,6 +2105,9 @@ class OutputPage extends ContextSource { /** * Parse wikitext, strip paragraphs, and return the HTML. * + * @todo This doesn't work as expected at all. If $interface is false, there's always a + * wrapping
, so stripOuterParagraph() does nothing. + * * @param string $text * @param bool $linestart Is this the start of a line? * @param bool $interface Use interface language (instead of content language) while parsing @@ -2144,8 +2152,6 @@ class OutputPage extends ContextSource { * @param string|int|float|bool|null $mtime Last-Modified timestamp * @param int $minTTL Minimum TTL in seconds [default: 1 minute] * @param int $maxTTL Maximum TTL in seconds [default: $wgSquidMaxage] - * @return int TTL in seconds passed to lowerCdnMaxage() (may not be the same as the new - * s-maxage) * @since 1.28 */ public function adaptCdnTTL( $mtime, $minTTL = 0, $maxTTL = 0 ) { @@ -2156,13 +2162,11 @@ class OutputPage extends ContextSource { return $minTTL; // entity does not exist } - $age = time() - wfTimestamp( TS_UNIX, $mtime ); + $age = MWTimestamp::time() - wfTimestamp( TS_UNIX, $mtime ); $adaptiveTTL = max( 0.9 * $age, $minTTL ); $adaptiveTTL = min( $adaptiveTTL, $maxTTL ); $this->lowerCdnMaxage( (int)$adaptiveTTL ); - - return $adaptiveTTL; } /** @@ -2182,19 +2186,18 @@ class OutputPage extends ContextSource { * @return array */ function getCacheVaryCookies() { - static $cookies; - if ( $cookies === null ) { + if ( self::$cacheVaryCookies === null ) { $config = $this->getConfig(); - $cookies = array_merge( + self::$cacheVaryCookies = array_values( array_unique( array_merge( SessionManager::singleton()->getVaryCookies(), [ 'forceHTTPS', ], $config->get( 'CacheVaryCookies' ) - ); - Hooks::run( 'GetCacheVaryCookies', [ $this, &$cookies ] ); + ) ) ); + Hooks::run( 'GetCacheVaryCookies', [ $this, &self::$cacheVaryCookies ] ); } - return $cookies; + return self::$cacheVaryCookies; } /** @@ -2278,8 +2281,12 @@ class OutputPage extends ContextSource { * Get a complete Key header * * @return string + * @deprecated in 1.32; the IETF spec for this header expired w/o becoming + * a standard. */ public function getKeyHeader() { + wfDeprecated( '$wgUseKeyHeader', '1.32' ); + $cvCookies = $this->getCacheVaryCookies(); $cookiesOption = []; @@ -2327,6 +2334,16 @@ class OutputPage extends ContextSource { continue; } + // XXX Note that this code is not strictly correct: we + // do a case-insensitive match in + // LanguageConverter::getHeaderVariant() while the + // (abandoned, draft) spec for the `Key` header only + // allows case-sensitive matches. To match the logic + // in LanguageConverter::getHeaderVariant() we should + // also be looking at fallback variants and deprecated + // mediawiki-internal codes, as well as BCP 47 + // normalized forms. + $aloption[] = "substr=$variant"; // IE and some other browsers use BCP 47 standards in their Accept-Language header, diff --git a/includes/Revision/RenderedRevision.php b/includes/Revision/RenderedRevision.php index 9cb20e0c01..6eee3c4cf6 100644 --- a/includes/Revision/RenderedRevision.php +++ b/includes/Revision/RenderedRevision.php @@ -31,6 +31,7 @@ use Psr\Log\NullLogger; use Revision; use Title; use User; +use Content; use Wikimedia\Assert\Assert; /** @@ -207,12 +208,7 @@ class RenderedRevision implements SlotRenderingProvider { 'Access to the content has been suppressed for this audience' ); } else { - $output = $content->getParserOutput( - $this->title, - $this->revision->getId(), - $this->options, - $withHtml - ); + $output = $this->getSlotParserOutputUncached( $content, $withHtml ); if ( $withHtml && !$output->hasText() ) { throw new LogicException( @@ -232,6 +228,21 @@ class RenderedRevision implements SlotRenderingProvider { return $this->slotsOutput[$role]; } + /** + * @note This method exist to make duplicate parses easier to see during profiling + * @param Content $content + * @param bool $withHtml + * @return ParserOutput + */ + private function getSlotParserOutputUncached( Content $content, $withHtml ) { + return $content->getParserOutput( + $this->title, + $this->revision->getId(), + $this->options, + $withHtml + ); + } + /** * Updates the RevisionRecord after the revision has been saved. This can be used to discard * and cached ParserOutput so parser functions like {{REVISIONTIMESTAMP}} or {{REVISIONID}} diff --git a/includes/Setup.php b/includes/Setup.php index 43bc2d8de3..bdfce62293 100644 --- a/includes/Setup.php +++ b/includes/Setup.php @@ -35,6 +35,14 @@ if ( !defined( 'MEDIAWIKI' ) ) { exit( 1 ); } +// Check to see if we are at the file scope +$wgScopeTest = 'MediaWiki Setup.php scope test'; +if ( !isset( $GLOBALS['wgScopeTest'] ) || $GLOBALS['wgScopeTest'] !== $wgScopeTest ) { + echo "Error, Setup.php must be included from the file scope.\n"; + die( 1 ); +} +unset( $wgScopeTest ); + /** * Pre-config setup: Before loading LocalSettings.php */ @@ -118,12 +126,6 @@ ExtensionRegistry::getInstance()->loadFromQueue(); // Don't let any other extensions load ExtensionRegistry::getInstance()->finish(); -// Check to see if we are at the file scope -if ( !isset( $wgVersion ) ) { - echo "Error, Setup.php must be included from the file scope, after DefaultSettings.php\n"; - die( 1 ); -} - mb_internal_encoding( 'UTF-8' ); // Set the configured locale on all requests for consisteny diff --git a/includes/actions/Action.php b/includes/actions/Action.php index fb761a7565..e5233f0607 100644 --- a/includes/actions/Action.php +++ b/includes/actions/Action.php @@ -30,7 +30,7 @@ use MediaWiki\MediaWikiServices; * are distinct from Special Pages because an action must apply to exactly one page. * * To add an action in an extension, create a subclass of Action, and add the key to - * $wgActions. There is also the deprecated UnknownAction hook + * $wgActions. * * Actions generally fall into two groups: the show-a-form-then-do-something-with-the-input * format (protect, delete, move, etc), and the just-do-something format (watch, rollback, diff --git a/includes/api/ApiQuerySiteinfo.php b/includes/api/ApiQuerySiteinfo.php index 697eab69ba..d134edae78 100644 --- a/includes/api/ApiQuerySiteinfo.php +++ b/includes/api/ApiQuerySiteinfo.php @@ -701,7 +701,10 @@ class ApiQuerySiteinfo extends ApiQueryBase { $data = []; foreach ( $langNames as $code => $name ) { - $lang = [ 'code' => $code ]; + $lang = [ + 'code' => $code, + 'bcp47' => LanguageCode::bcp47( $code ), + ]; ApiResult::setContentValue( $lang, 'name', $name ); $data[] = $lang; } diff --git a/includes/cache/MessageCache.php b/includes/cache/MessageCache.php index 76d31ff871..4e0d0a7580 100644 --- a/includes/cache/MessageCache.php +++ b/includes/cache/MessageCache.php @@ -464,13 +464,7 @@ class MessageCache { $cache = []; - # Common conditions - $conds = [ - 'page_is_redirect' => 0, - 'page_namespace' => NS_MEDIAWIKI, - ]; - - $mostused = []; + $mostused = []; // list of "/" if ( $wgAdaptiveMessageCache && $code !== $wgLanguageCode ) { if ( !$this->cache->has( $wgLanguageCode ) ) { $this->load( $wgLanguageCode ); @@ -481,6 +475,14 @@ class MessageCache { } } + // Get the list of software-defined messages in core/extensions + $overridable = array_flip( Language::getMessageKeysFor( $wgLanguageCode ) ); + + // Common conditions + $conds = [ + 'page_is_redirect' => 0, + 'page_namespace' => NS_MEDIAWIKI, + ]; if ( count( $mostused ) ) { $conds['page_title'] = $mostused; } elseif ( $code !== $wgLanguageCode ) { @@ -492,31 +494,28 @@ class MessageCache { $dbr->buildLike( $dbr->anyString(), '/', $dbr->anyString() ); } - # Conditions to fetch oversized pages to ignore them - $bigConds = $conds; - $bigConds[] = 'page_len > ' . intval( $wgMaxMsgCacheEntrySize ); - - # Load titles for all oversized pages in the MediaWiki namespace + // Set the stubs for oversized software-defined messages in the main cache map $res = $dbr->select( 'page', [ 'page_title', 'page_latest' ], - $bigConds, + array_merge( $conds, [ 'page_len > ' . intval( $wgMaxMsgCacheEntrySize ) ] ), __METHOD__ . "($code)-big" ); foreach ( $res as $row ) { - $cache[$row->page_title] = '!TOO BIG'; + $name = $this->contLang->lcfirst( $row->page_title ); + // Include entries/stubs for all keys in $mostused in adaptive mode + if ( $wgAdaptiveMessageCache || isset( $overridable[$name] ) ) { + $cache[$row->page_title] = '!TOO BIG'; + } // At least include revision ID so page changes are reflected in the hash $cache['EXCESSIVE'][$row->page_title] = $row->page_latest; } - # Conditions to load the remaining pages with their contents - $smallConds = $conds; - $smallConds[] = 'page_len <= ' . intval( $wgMaxMsgCacheEntrySize ); - + // Set the text for small software-defined messages in the main cache map $res = $dbr->select( [ 'page', 'revision', 'text' ], - [ 'page_title', 'old_id', 'old_text', 'old_flags' ], - $smallConds, + [ 'page_title', 'page_latest', 'old_id', 'old_text', 'old_flags' ], + array_merge( $conds, [ 'page_len <= ' . intval( $wgMaxMsgCacheEntrySize ) ] ), __METHOD__ . "($code)-small", [], [ @@ -524,23 +523,30 @@ class MessageCache { 'text' => [ 'JOIN', 'rev_text_id=old_id' ], ] ); - foreach ( $res as $row ) { - $text = Revision::getRevisionText( $row ); - if ( $text === false ) { - // Failed to fetch data; possible ES errors? - // Store a marker to fetch on-demand as a workaround... - // TODO Use a differnt marker - $entry = '!TOO BIG'; - wfDebugLog( - 'MessageCache', - __METHOD__ - . ": failed to load message page text for {$row->page_title} ($code)" - ); + $name = $this->contLang->lcfirst( $row->page_title ); + // Include entries/stubs for all keys in $mostused in adaptive mode + if ( $wgAdaptiveMessageCache || isset( $overridable[$name] ) ) { + $text = Revision::getRevisionText( $row ); + if ( $text === false ) { + // Failed to fetch data; possible ES errors? + // Store a marker to fetch on-demand as a workaround... + // TODO Use a differnt marker + $entry = '!TOO BIG'; + wfDebugLog( + 'MessageCache', + __METHOD__ + . ": failed to load message page text for {$row->page_title} ($code)" + ); + } else { + $entry = ' ' . $text; + } + $cache[$row->page_title] = $entry; } else { - $entry = ' ' . $text; + // T193271: cache object gets too big and slow to generate. + // At least include revision ID so page changes are reflected in the hash. + $cache['EXCESSIVE'][$row->page_title] = $row->page_latest; } - $cache[$row->page_title] = $entry; } $cache['VERSION'] = MSG_CACHE_VERSION; @@ -613,11 +619,17 @@ class MessageCache { return; } - // Reload messages from the database and pre-populate dc-local caches - // as optimisation. Use the master DB to avoid race conditions. - $cache = $this->loadFromDB( $code, self::FOR_UPDATE ); + // Load the existing cache to update it in the local DC cache. + // The other DCs will see a hash mismatch. + if ( $this->load( $code, self::FOR_UPDATE ) ) { + $cache = $this->cache->get( $code ); + } else { + // Err? Fall back to loading from the database. + $cache = $this->loadFromDB( $code, self::FOR_UPDATE ); + } // Check if individual cache keys should exist and update cache accordingly $newTextByTitle = []; // map of (title => content) + $newBigTitles = []; // map of (title => latest revision ID), like EXCESSIVE in loadFromDB() foreach ( $replacements as list( $title ) ) { $page = WikiPage::factory( Title::makeTitle( NS_MEDIAWIKI, $title ) ); $page->loadPageData( $page::READ_LATEST ); @@ -625,15 +637,29 @@ class MessageCache { // Remember the text for the blob store update later on $newTextByTitle[$title] = $text; // Note that if $text is false, then $cache should have a !NONEXISTANT entry - if ( is_string( $text ) && strlen( $text ) > $wgMaxMsgCacheEntrySize ) { - // Match logic of loadCachedMessagePageEntry() - $this->wanCache->set( - $this->bigMessageCacheKey( $cache['HASH'], $title ), - ' ' . $text, - $this->mExpiry - ); + if ( !is_string( $text ) ) { + $cache[$title] = '!NONEXISTENT'; + } elseif ( strlen( $text ) > $wgMaxMsgCacheEntrySize ) { + $cache[$title] = '!TOO BIG'; + $newBigTitles[$title] = $page->getLatest(); + } else { + $cache[$title] = ' ' . $text; } } + // Update HASH for the new key. Incorporates various administrative keys, + // including the old HASH (and thereby the EXCESSIVE value from loadFromDB() + // and previous replace() calls), but that doesn't really matter since we + // only ever compare it for equality with a copy saved by saveToCaches(). + $cache['HASH'] = md5( serialize( $cache + [ 'EXCESSIVE' => $newBigTitles ] ) ); + // Update the too-big WAN cache entries now that we have the new HASH + foreach ( $newBigTitles as $title => $id ) { + // Match logic of loadCachedMessagePageEntry() + $this->wanCache->set( + $this->bigMessageCacheKey( $cache['HASH'], $title ), + ' ' . $newTextByTitle[$title], + $this->mExpiry + ); + } // Mark this cache as definitely being "latest" (non-volatile) so // load() calls do not try to refresh the cache with replica DB data $cache['LATEST'] = time(); @@ -818,9 +844,8 @@ class MessageCache { Hooks::run( 'MessageCache::get', [ &$lckey ] ); // Loop through each language in the fallback list until we find something useful - $lang = wfGetLangObj( $langcode ); $message = $this->getMessageFromFallbackChain( - $lang, + wfGetLangObj( $langcode ), $lckey, !$this->mDisable && $useDB ); @@ -912,7 +937,6 @@ class MessageCache { $this->getMessagePageName( $langcode, $uckey ), $langcode ); - if ( $message !== false ) { return $message; } @@ -987,44 +1011,54 @@ class MessageCache { $this->load( $code ); $entry = $this->cache->getField( $code, $title ); + if ( $entry !== null ) { + // Message page exists as an override of a software messages if ( substr( $entry, 0, 1 ) === ' ' ) { // The message exists and is not '!TOO BIG' return (string)substr( $entry, 1 ); } elseif ( $entry === '!NONEXISTENT' ) { + // The text might be '-' or missing due to some data loss return false; } - // Fall through and try invididual message cache below - } else { - // Message does not have a MediaWiki page definition - $message = false; - Hooks::run( 'MessagesPreLoad', [ $title, &$message, $code ] ); - if ( $message !== false ) { - $this->cache->setField( $code, $title, ' ' . $message ); - } else { - $this->cache->setField( $code, $title, '!NONEXISTENT' ); - } - - return $message; - } - - if ( $this->cacheVolatile[$code] ) { - $entry = false; - // Make sure that individual keys respect the WAN cache holdoff period too - LoggerFactory::getInstance( 'MessageCache' )->debug( - __METHOD__ . ': loading volatile key \'{titleKey}\'', - [ 'titleKey' => $title, 'code' => $code ] ); + // Load the message page, utilizing the individual message cache. + // If the page does not exist, there will be no hook handler fallbacks. + $entry = $this->loadCachedMessagePageEntry( + $title, + $code, + $this->cache->getField( $code, 'HASH' ) + ); } else { - // Try the individual message cache + // Message page does not exist or does not override a software message. + // Load the message page, utilizing the individual message cache. $entry = $this->loadCachedMessagePageEntry( $title, $code, $this->cache->getField( $code, 'HASH' ) ); + if ( substr( $entry, 0, 1 ) !== ' ' ) { + // Message does not have a MediaWiki page definition; try hook handlers + $message = false; + Hooks::run( 'MessagesPreLoad', [ $title, &$message, $code ] ); + if ( $message !== false ) { + $this->cache->setField( $code, $title, ' ' . $message ); + } else { + $this->cache->setField( $code, $title, '!NONEXISTENT' ); + } + + return $message; + } } if ( $entry !== false && substr( $entry, 0, 1 ) === ' ' ) { - $this->cache->setField( $code, $title, $entry ); + if ( $this->cacheVolatile[$code] ) { + // Make sure that individual keys respect the WAN cache holdoff period too + LoggerFactory::getInstance( 'MessageCache' )->debug( + __METHOD__ . ': loading volatile key \'{titleKey}\'', + [ 'titleKey' => $title, 'code' => $code ] ); + } else { + $this->cache->setField( $code, $title, $entry ); + } // The message exists, so make sure a string is returned return (string)substr( $entry, 1 ); } diff --git a/includes/cache/localisation/LCStoreDB.php b/includes/cache/localisation/LCStoreDB.php index 2d8e4d21a9..88a70421fa 100644 --- a/includes/cache/localisation/LCStoreDB.php +++ b/includes/cache/localisation/LCStoreDB.php @@ -85,22 +85,28 @@ class LCStoreDB implements LCStore { throw new MWException( __CLASS__ . ': must call startWrite() before finishWrite()' ); } - $dbw = $this->getWriteConnection(); - $dbw->startAtomic( __METHOD__ ); + $trxProfiler = Profiler::instance()->getTransactionProfiler(); + $oldSilenced = $trxProfiler->setSilenced( true ); try { - $dbw->delete( 'l10n_cache', [ 'lc_lang' => $this->currentLang ], __METHOD__ ); - foreach ( array_chunk( $this->batch, 500 ) as $rows ) { - $dbw->insert( 'l10n_cache', $rows, __METHOD__ ); - } - $this->writesDone = true; - } catch ( DBQueryError $e ) { - if ( $dbw->wasReadOnlyError() ) { - $this->readOnly = true; // just avoid site down time - } else { - throw $e; + $dbw = $this->getWriteConnection(); + $dbw->startAtomic( __METHOD__ ); + try { + $dbw->delete( 'l10n_cache', [ 'lc_lang' => $this->currentLang ], __METHOD__ ); + foreach ( array_chunk( $this->batch, 500 ) as $rows ) { + $dbw->insert( 'l10n_cache', $rows, __METHOD__ ); + } + $this->writesDone = true; + } catch ( DBQueryError $e ) { + if ( $dbw->wasReadOnlyError() ) { + $this->readOnly = true; // just avoid site down time + } else { + throw $e; + } } + $dbw->endAtomic( __METHOD__ ); + } finally { + $trxProfiler->setSilenced( $oldSilenced ); } - $dbw->endAtomic( __METHOD__ ); $this->currentLang = null; $this->batch = []; diff --git a/includes/deferred/DeferredUpdates.php b/includes/deferred/DeferredUpdates.php index e6a0e81779..b97bd216c0 100644 --- a/includes/deferred/DeferredUpdates.php +++ b/includes/deferred/DeferredUpdates.php @@ -79,7 +79,11 @@ class DeferredUpdates { public static function addUpdate( DeferrableUpdate $update, $stage = self::POSTSEND ) { global $wgCommandLineMode; - if ( self::$executeContext && self::$executeContext['stage'] >= $stage ) { + if ( + self::$executeContext && + self::$executeContext['stage'] >= $stage && + !( $update instanceof MergeableUpdate ) + ) { // This is a sub-DeferredUpdate; run it right after its parent update. // Also, while post-send updates are running, push any "pre-send" jobs to the // active post-send queue to make sure they get run this round (or at all). @@ -125,14 +129,17 @@ class DeferredUpdates { */ public static function doUpdates( $mode = 'run', $stage = self::ALL ) { $stageEffective = ( $stage === self::ALL ) ? self::POSTSEND : $stage; + // For ALL mode, make sure that any PRESEND updates added along the way get run. + // Normally, these use the subqueue, but that isn't true for MergeableUpdate items. + do { + if ( $stage === self::ALL || $stage === self::PRESEND ) { + self::execute( self::$preSendUpdates, $mode, $stageEffective ); + } - if ( $stage === self::ALL || $stage === self::PRESEND ) { - self::execute( self::$preSendUpdates, $mode, $stageEffective ); - } - - if ( $stage === self::ALL || $stage == self::POSTSEND ) { - self::execute( self::$postSendUpdates, $mode, $stageEffective ); - } + if ( $stage === self::ALL || $stage == self::POSTSEND ) { + self::execute( self::$postSendUpdates, $mode, $stageEffective ); + } + } while ( $stage === self::ALL && self::$preSendUpdates ); } /** @@ -146,6 +153,10 @@ class DeferredUpdates { /** @var MergeableUpdate $existingUpdate */ $existingUpdate = $queue[$class]; $existingUpdate->merge( $update ); + // Move the update to the end to handle things like mergeable purge + // updates that might depend on the prior updates in the queue running + unset( $queue[$class] ); + $queue[$class] = $existingUpdate; } else { $queue[$class] = $update; } diff --git a/includes/installer/i18n/ar.json b/includes/installer/i18n/ar.json index 59767aac39..8112ccb862 100644 --- a/includes/installer/i18n/ar.json +++ b/includes/installer/i18n/ar.json @@ -319,6 +319,8 @@ "config-skins-screenshots": "$1 (لقطات شاشة: $2)", "config-extensions-requires": "$1 (يتطلب $2)", "config-screenshot": "لقطة شاشة", + "config-extension-not-found": "لم يمكن العثور على ملف التسجيل للامتداد \"$1\"", + "config-extension-dependency": "خطأ اعتماد حدث أثناء تنصيب الامتداد \"$1\": $2", "mainpagetext": "تم تثبيت ميدياويكي بنجاح.", "mainpagedocfooter": "استشر [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents دليل المستخدم] لمعلومات حول استخدام برنامج الويكي.\n\n== البداية ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings قائمة إعدادات الضبط]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ أسئلة متكررة حول ميدياويكي]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce القائمة البريدية الخاصة بإصدار ميدياويكي]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam تعلم كيفية مكافحة السخام في الويكي الخاص بك]" } diff --git a/includes/installer/i18n/de.json b/includes/installer/i18n/de.json index c8a232b4f0..16d7c93450 100644 --- a/includes/installer/i18n/de.json +++ b/includes/installer/i18n/de.json @@ -325,6 +325,8 @@ "config-skins-screenshot": "$1 ($2)", "config-extensions-requires": "$1 (erfordert $2)", "config-screenshot": "Bildschirmfoto", + "config-extension-not-found": "Die Registrierungsdatei für die Erweiterung „$1“ konnte nicht gefunden werden", + "config-extension-dependency": "Bei der Installation der Erweiterung „$1“ ist ein Abhängigkeitsfehler aufgetreten: $2", "mainpagetext": "MediaWiki wurde installiert.", "mainpagedocfooter": "Hilfe zur Benutzung und Konfiguration der Wiki-Software findest du im [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Benutzerhandbuch].\n\n== Starthilfen ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Liste der Konfigurationsvariablen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki-FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailingliste neuer MediaWiki-Versionen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Übersetze MediaWiki für deine Sprache]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Erfahre, wie du Spam auf deinem Wiki bekämpfen kannst]" } diff --git a/includes/installer/i18n/fr.json b/includes/installer/i18n/fr.json index 0ee67e8c5c..95fd726fca 100644 --- a/includes/installer/i18n/fr.json +++ b/includes/installer/i18n/fr.json @@ -338,6 +338,8 @@ "config-skins-screenshots": "$1 (captures d’écran : $2)", "config-extensions-requires": "$1 (nécessite $2)", "config-screenshot": "Captures d’écrans", + "config-extension-not-found": "Impossible de trouver le fichier d’inscription pour l’extension « $1 »", + "config-extension-dependency": "Une erreur de dépendance s’est produite en installant l’extension « $1 » : $2", "mainpagetext": "MediaWiki a été installé.", "mainpagedocfooter": "Consultez le [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guide de l’utilisateur] pour plus d’informations sur l’utilisation de ce logiciel de wiki.\n\n== Pour démarrer ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Liste des paramètres de configuration]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/fr Questions courantes sur MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Liste de discussion sur les distributions de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Adaptez MediaWiki dans votre langue]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Apprendre comment lutter contre le pourriel dans votre wiki]" } diff --git a/includes/installer/i18n/it.json b/includes/installer/i18n/it.json index b8db48900c..5261427d1e 100644 --- a/includes/installer/i18n/it.json +++ b/includes/installer/i18n/it.json @@ -322,6 +322,8 @@ "config-nofile": "Il file \"$1\" non può essere trovato. È stato eliminato?", "config-extension-link": "Sapevi che il tuo wiki supporta le [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions estensioni]?\n\nPuoi navigare tra le [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category estensioni per categoria].", "config-extensions-requires": "$1 (richiesto $2)", + "config-extension-not-found": "Impossibile trovare il file di registrazione per l'estensione \"$1\"", + "config-extension-dependency": "Si è verificato un errore di dipendenza durante l'installazione dell'estensione \"$1\": $2", "mainpagetext": "MediaWiki è stato installato.", "mainpagedocfooter": "Consulta la [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents guida utente] per maggiori informazioni sull'uso di questo software wiki.\n\n== Per iniziare ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Impostazioni di configurazione]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Domande frequenti su MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailing list annunci MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Trova MediaWiki nella tua lingua]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Imparare a combattere lo spam sul tuo wiki]" } diff --git a/includes/installer/i18n/pt-br.json b/includes/installer/i18n/pt-br.json index f1157c5766..e4a37348ca 100644 --- a/includes/installer/i18n/pt-br.json +++ b/includes/installer/i18n/pt-br.json @@ -330,6 +330,8 @@ "config-skins-screenshots": "$1 (screenshots: $2)", "config-extensions-requires": "$1 (requer $2)", "config-screenshot": "screenshot", + "config-extension-not-found": "Não foi possível encontrar o arquivo de registo da extensão \"$1\"", + "config-extension-dependency": "Foi encontrado um erro de dependências ao instalar a extensão \"$1\": $2", "mainpagetext": "O MediaWiki foi instalado.", "mainpagedocfooter": "Consulte o [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Manual de Usuário] para informações de como usar o software wiki.\n\n== Começando ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista de opções de configuração]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ do MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista de discussão com avisos de novas versões do MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Traduza o MediaWiki para seu idioma]" } diff --git a/includes/installer/i18n/pt.json b/includes/installer/i18n/pt.json index cdcac45ab5..0495d50f1c 100644 --- a/includes/installer/i18n/pt.json +++ b/includes/installer/i18n/pt.json @@ -319,7 +319,7 @@ "config-install-mainpage-failed": "Não foi possível inserir a página principal: $1", "config-install-done": "Parabéns!\nTerminou a instalação do MediaWiki.\n\nO instalador gerou um ficheiro LocalSettings.php.\nEste ficheiro contém todas as configurações.\n\nPrecisa de descarregar o ficheiro e colocá-lo no diretório de raiz da sua instalação (o mesmo diretório onde está o ficheiro index.php). Este descarregamento deverá ter sido iniciado automaticamente.\n\nSe o descarregamento não foi iniciado, ou se o cancelou, pode recomeçá-lo clicando na hiperligação abaixo:\n\n$3\n\nNota: Se não o descarregar agora, o ficheiro que foi gerado deixará de estar disponível quando sair do processo de instalação.\n\nDepois de terminar o passo anterior, pode [$2 entrar na wiki].", "config-install-done-path": "Parabéns!\nTerminou a instalação do MediaWiki.\n\nO instalador gerou um ficheiro LocalSettings.php.\nEste ficheiro contém todas as configurações.\n\nPrecisa de descarregar o ficheiro e colocá-lo no diretório $4. Este descarregamento deverá ter sido iniciado automaticamente.\n\nSe o descarregamento não foi iniciado, ou se o cancelou, pode recomeçá-lo clicando a hiperligação abaixo:\n\n$3\n\nNota: Se não fizer o descarregamento agora, o ficheiro que foi gerado deixará de estar disponível quando sair do processo de instalação.\n\nDepois de terminar o passo anterior, pode [$2 entrar na wiki].", - "config-install-success": "O MediaWiki foi instalado. Já pode visitar <$1$2> para ver a sua wiki.\nSe tiver dúvidas, veja a nossa lista de perguntas frequentes,\n, ou utilize um dos fóruns de suporte indicados nessa página.", + "config-install-success": "O MediaWiki foi instalado. Já pode visitar <$1$2> para ver a sua wiki.\nSe tiver dúvidas, veja a nossa lista de perguntas frequentes,\n, ou utilize um dos fóruns de suporte indicados nessa página.", "config-download-localsettings": "Descarregar LocalSettings.php", "config-help": "ajuda", "config-help-tooltip": "clique para expandir", @@ -328,6 +328,8 @@ "config-skins-screenshots": "$1 (capturas de ecrã: $2)", "config-extensions-requires": "$1 (requer $2)", "config-screenshot": "captura de ecrã", + "config-extension-not-found": "Não foi possível encontrar o ficheiro de registo da extensão \"$1\"", + "config-extension-dependency": "Foi encontrado um erro de dependências ao instalar a extensão \"$1\": $2", "mainpagetext": "O MediaWiki foi instalado.", "mainpagedocfooter": "Consulte a [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Ajuda do MediaWiki] para informações sobre o uso do software wiki.\n\n== Onde começar ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista de opções de configuração]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Perguntas e respostas frequentes sobre o MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Subscreva a lista de divulgação de novas versões do MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Regionalize o MediaWiki para a sua língua]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Aprenda a combater spam na sua wiki]" } diff --git a/includes/installer/i18n/sr-ec.json b/includes/installer/i18n/sr-ec.json index cadd862fee..58c40ce430 100644 --- a/includes/installer/i18n/sr-ec.json +++ b/includes/installer/i18n/sr-ec.json @@ -187,6 +187,7 @@ "config-skins-screenshot": "$1 ($2)", "config-extensions-requires": "$1 (захтева $2)", "config-screenshot": "снимак екрана", + "config-extension-not-found": "Није могуће пронаћи датотеку регистрације за додатак „$1”", "mainpagetext": "Медијавики је инсталиран.", "mainpagedocfooter": "Погледајте [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents кориснички водич] за коришћење програма.\n\n== Увод ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Помоћ у вези са подешавањима]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Често постављана питања]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Дописни списак о издањима Медијавикија]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Научите како да се борите против спама на свом викију]" } diff --git a/includes/installer/i18n/zh-hant.json b/includes/installer/i18n/zh-hant.json index 0ed6f6fe07..3fa0f65585 100644 --- a/includes/installer/i18n/zh-hant.json +++ b/includes/installer/i18n/zh-hant.json @@ -326,6 +326,8 @@ "config-skins-screenshots": "$1 (螢幕截圖: $2)", "config-extensions-requires": "$1(需要 $2)", "config-screenshot": "螢幕截圖", + "config-extension-not-found": "查無用於擴充功能「$1」的註冊檔案", + "config-extension-dependency": "當安裝擴充功能「$1」時發生相依性錯誤:$2", "mainpagetext": "已安裝 MediaWiki。", "mainpagedocfooter": "有關使用wiki的訊息,請參閱[https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents 使用者指南]。\n\n== 新手入門 ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings 系統設定]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki常見問題]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki郵寄清單]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources 將MediaWiki翻譯至您的語言]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam 了解如何在您的wiki上防禦破壞]" } diff --git a/includes/objectcache/SqlBagOStuff.php b/includes/objectcache/SqlBagOStuff.php index 076f208bf0..a581ac8f8c 100644 --- a/includes/objectcache/SqlBagOStuff.php +++ b/includes/objectcache/SqlBagOStuff.php @@ -28,7 +28,7 @@ use Wikimedia\Rdbms\DBError; use Wikimedia\Rdbms\DBQueryError; use Wikimedia\Rdbms\DBConnectionError; use Wikimedia\Rdbms\LoadBalancer; -use Wikimedia\Rdbms\TransactionProfiler; +use Wikimedia\ScopedCallback; use Wikimedia\WaitConditionLoop; /** @@ -171,8 +171,6 @@ class SqlBagOStuff extends BagOStuff { $type = $info['type'] ?? 'mysql'; $host = $info['host'] ?? '[unknown]'; $this->logger->debug( __CLASS__ . ": connecting to $host" ); - // Use a blank trx profiler to ignore expections as this is a cache - $info['trxProfiler'] = new TransactionProfiler(); $db = Database::factory( $type, $info ); $db->clearFlag( DBO_TRX ); // auto-commit mode } else { @@ -182,7 +180,6 @@ class SqlBagOStuff extends BagOStuff { if ( $lb->getServerType( $lb->getWriterIndex() ) !== 'sqlite' ) { // Keep a separate connection to avoid contention and deadlocks $db = $lb->getConnection( $index, [], false, $lb::CONN_TRX_AUTOCOMMIT ); - // @TODO: Use a blank trx profiler to ignore expections as this is a cache } else { // However, SQLite has the opposite behavior due to DB-level locking. // Stock sqlite MediaWiki installs use a separate sqlite cache DB instead. @@ -323,6 +320,7 @@ class SqlBagOStuff extends BagOStuff { $result = true; $exptime = (int)$expiry; + $silenceScope = $this->silenceTransactionProfiler(); foreach ( $keysByTable as $serverIndex => $serverKeys ) { $db = null; try { @@ -384,6 +382,7 @@ class SqlBagOStuff extends BagOStuff { protected function cas( $casToken, $key, $value, $exptime = 0 ) { list( $serverIndex, $tableName ) = $this->getTableByKey( $key ); $db = null; + $silenceScope = $this->silenceTransactionProfiler(); try { $db = $this->getDB( $serverIndex ); $exptime = intval( $exptime ); @@ -425,6 +424,7 @@ class SqlBagOStuff extends BagOStuff { public function delete( $key ) { list( $serverIndex, $tableName ) = $this->getTableByKey( $key ); $db = null; + $silenceScope = $this->silenceTransactionProfiler(); try { $db = $this->getDB( $serverIndex ); $db->delete( @@ -442,6 +442,7 @@ class SqlBagOStuff extends BagOStuff { public function incr( $key, $step = 1 ) { list( $serverIndex, $tableName ) = $this->getTableByKey( $key ); $db = null; + $silenceScope = $this->silenceTransactionProfiler(); try { $db = $this->getDB( $serverIndex ); $step = intval( $step ); @@ -496,6 +497,7 @@ class SqlBagOStuff extends BagOStuff { public function changeTTL( $key, $expiry = 0 ) { list( $serverIndex, $tableName ) = $this->getTableByKey( $key ); $db = null; + $silenceScope = $this->silenceTransactionProfiler(); try { $db = $this->getDB( $serverIndex ); $db->update( @@ -564,6 +566,7 @@ class SqlBagOStuff extends BagOStuff { * @return bool */ public function deleteObjectsExpiringBefore( $timestamp, $progressCallback = false ) { + $silenceScope = $this->silenceTransactionProfiler(); for ( $serverIndex = 0; $serverIndex < $this->numServers; $serverIndex++ ) { $db = null; try { @@ -641,6 +644,7 @@ class SqlBagOStuff extends BagOStuff { * @return bool */ public function deleteAll() { + $silenceScope = $this->silenceTransactionProfiler(); for ( $serverIndex = 0; $serverIndex < $this->numServers; $serverIndex++ ) { $db = null; try { @@ -822,4 +826,18 @@ class SqlBagOStuff extends BagOStuff { return ( $loop->invoke() === $loop::CONDITION_REACHED ); } + + /** + * Returns a ScopedCallback which resets the silence flag in the transaction profiler when it is + * destroyed on the end of a scope, for example on return or throw + * @return ScopedCallback + * @since 1.32 + */ + protected function silenceTransactionProfiler() { + $trxProfiler = Profiler::instance()->getTransactionProfiler(); + $oldSilenced = $trxProfiler->setSilenced( true ); + return new ScopedCallback( function () use ( $trxProfiler, $oldSilenced ) { + $trxProfiler->setSilenced( $oldSilenced ); + } ); + } } diff --git a/includes/parser/Parser.php b/includes/parser/Parser.php index 90ef335809..dcb2c89db1 100644 --- a/includes/parser/Parser.php +++ b/includes/parser/Parser.php @@ -612,8 +612,6 @@ class Parser { // Since we're not really outputting HTML, decode the entities and // then re-encode the things that need hiding inside HTML comments. $limitReport = htmlspecialchars_decode( $limitReport ); - // Run deprecated hook - Hooks::run( 'ParserLimitReport', [ $this, &$limitReport ], '1.22' ); // Sanitize for comment. Note '‐' in the replacement is U+2010, // which looks much like the problematic '-'. diff --git a/includes/parser/ParserOutput.php b/includes/parser/ParserOutput.php index 48ba111e88..6d238caac6 100644 --- a/includes/parser/ParserOutput.php +++ b/includes/parser/ParserOutput.php @@ -338,7 +338,7 @@ class ParserOutput extends CacheTime { return $skin->doEditSectionLink( $editsectionPage, $editsectionSection, $editsectionContent, - $wgLang->getCode() + $wgLang ); }, $text diff --git a/includes/resourceloader/ResourceLoader.php b/includes/resourceloader/ResourceLoader.php index fe9ba74a4f..e2b60fc672 100644 --- a/includes/resourceloader/ResourceLoader.php +++ b/includes/resourceloader/ResourceLoader.php @@ -1524,13 +1524,21 @@ MESSAGE; * * @param array $configuration List of configuration values keyed by variable name * @return string JavaScript code + * @throws Exception */ public static function makeConfigSetScript( array $configuration ) { - return Xml::encodeJsCall( + $js = Xml::encodeJsCall( 'mw.config.set', [ $configuration ], self::inDebugMode() ); + if ( $js === false ) { + throw new Exception( + 'JSON serialization of config data failed. ' . + 'This usually means the config data is not valid UTF-8.' + ); + } + return $js; } /** diff --git a/includes/resourceloader/ResourceLoaderLanguageDataModule.php b/includes/resourceloader/ResourceLoaderLanguageDataModule.php index 4b24081109..f718e5feb3 100644 --- a/includes/resourceloader/ResourceLoaderLanguageDataModule.php +++ b/includes/resourceloader/ResourceLoaderLanguageDataModule.php @@ -46,6 +46,7 @@ class ResourceLoaderLanguageDataModule extends ResourceLoaderFileModule { 'pluralRules' => $language->getPluralRules(), 'digitGroupingPattern' => $language->digitGroupingPattern(), 'fallbackLanguages' => $language->getFallbackLanguages(), + 'bcp47Map' => LanguageCode::getNonstandardLanguageCodeMapping(), ]; } diff --git a/includes/skins/Skin.php b/includes/skins/Skin.php index ed4045d487..f545532382 100644 --- a/includes/skins/Skin.php +++ b/includes/skins/Skin.php @@ -1610,15 +1610,20 @@ abstract class Skin extends ContextSource { * @param string $section The designation of the section being pointed to, * to be included in the link, like "§ion=$section" * @param string|null $tooltip The tooltip to use for the link: will be escaped - * and wrapped in the 'editsectionhint' message - * @param string $lang Language code + * and wrapped in the 'editsectionhint' message. + * Not setting this parameter is deprecated. + * @param Language|string $lang Language object or language code string. + * Type string is deprecated. Not setting this parameter is deprecated. * @return string HTML to use for edit link */ public function doEditSectionLink( Title $nt, $section, $tooltip = null, $lang = false ) { // HTML generated here should probably have userlangattributes // added to it for LTR text on RTL pages - $lang = wfGetLangObj( $lang ); + if ( !$lang instanceof Language ) { + wfDeprecated( __METHOD__ . ' with other type than Language for $lang', '1.32' ); + $lang = wfGetLangObj( $lang ); + } $attribs = []; if ( !is_null( $tooltip ) ) { @@ -1659,12 +1664,6 @@ abstract class Skin extends ContextSource { ); $result .= ']'; - // Deprecated, use SkinEditSectionLinks hook instead - Hooks::run( - 'DoEditSectionLink', - [ $this, $nt, $section, $tooltip, &$result, $lang ], - '1.25' - ); return $result; } diff --git a/includes/specials/pagers/ActiveUsersPager.php b/includes/specials/pagers/ActiveUsersPager.php index 87c849aab2..552e92fb00 100644 --- a/includes/specials/pagers/ActiveUsersPager.php +++ b/includes/specials/pagers/ActiveUsersPager.php @@ -83,13 +83,14 @@ class ActiveUsersPager extends UsersPager { $activeUserSeconds = $this->getConfig()->get( 'ActiveUserDays' ) * 86400; $timestamp = $dbr->timestamp( wfTimestamp( TS_UNIX ) - $activeUserSeconds ); - $tables = [ 'querycachetwo', 'user', 'recentchanges' ] + $rcQuery['tables']; - $jconds = $rcQuery['joins']; + $tables = [ 'querycachetwo', 'user', 'rc' => [ 'recentchanges' ] + $rcQuery['tables'] ]; + $jconds = [ + 'user' => [ 'JOIN', 'user_name = qcc_title' ], + 'rc' => [ 'JOIN', $rcQuery['fields']['rc_user_text'] . ' = qcc_title' ], + ] + $rcQuery['joins']; $conds = [ 'qcc_type' => 'activeusers', 'qcc_namespace' => NS_USER, - 'user_name = qcc_title', - $rcQuery['fields']['rc_user_text'] . ' = qcc_title', 'rc_type != ' . $dbr->addQuotes( RC_EXTERNAL ), // Don't count wikidata. 'rc_type != ' . $dbr->addQuotes( RC_CATEGORIZE ), // Don't count categorization changes. 'rc_log_type IS NULL OR rc_log_type != ' . $dbr->addQuotes( 'newusers' ), @@ -100,7 +101,7 @@ class ActiveUsersPager extends UsersPager { } if ( $this->groups !== [] ) { $tables[] = 'user_groups'; - $conds[] = 'ug_user = user_id'; + $jconds['user_groups'] = [ 'JOIN', [ 'ug_user = user_id' ] ]; $conds['ug_group'] = $this->groups; $conds[] = 'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() ); } diff --git a/includes/user/User.php b/includes/user/User.php index ada5dc9ce5..fe9a5c9ebb 100644 --- a/includes/user/User.php +++ b/includes/user/User.php @@ -5123,16 +5123,13 @@ class User implements IDBAccessObject, UserIdentity { /** * Get a list of implicit groups + * TODO: Should we deprecate this? It's trivial, but we don't want to encourage use of globals. + * * @return array Array of Strings Array of internal group names */ public static function getImplicitGroups() { global $wgImplicitGroups; - - $groups = $wgImplicitGroups; - # Deprecated, use $wgImplicitGroups instead - Hooks::run( 'UserGetImplicitGroups', [ &$groups ], '1.25' ); - - return $groups; + return $wgImplicitGroups; } /** diff --git a/languages/FakeConverter.php b/languages/FakeConverter.php index c4ec6382e5..2fc85e5d1b 100644 --- a/languages/FakeConverter.php +++ b/languages/FakeConverter.php @@ -116,6 +116,10 @@ class FakeConverter { } function validateVariant( $variant = null ) { + if ( $variant === null ) { + return null; + } + $variant = strtolower( $variant ); return $variant === $this->mLang->getCode() ? $variant : null; } diff --git a/languages/LanguageCode.php b/languages/LanguageCode.php index f50c55fe76..b0baec1341 100644 --- a/languages/LanguageCode.php +++ b/languages/LanguageCode.php @@ -30,22 +30,85 @@ class LanguageCode { /** * Mapping of deprecated language codes that were used in previous * versions of MediaWiki to up-to-date, current language codes. + * These may or may not be valid BCP 47 codes; they are included here + * because MediaWiki remapped these particular codes at some point. * * @var array Mapping from language code to language code * * @since 1.30 + * @see https://meta.wikimedia.org/wiki/Special_language_codes */ private static $deprecatedLanguageCodeMapping = [ // Note that als is actually a valid ISO 639 code (Tosk Albanian), but it // was previously used in MediaWiki for Alsatian, which comes under gsw - 'als' => 'gsw', - 'bat-smg' => 'sgs', - 'be-x-old' => 'be-tarask', - 'fiu-vro' => 'vro', - 'roa-rup' => 'rup', - 'zh-classical' => 'lzh', - 'zh-min-nan' => 'nan', - 'zh-yue' => 'yue', + 'als' => 'gsw', // T25215 + 'bat-smg' => 'sgs', // T27522 + 'be-x-old' => 'be-tarask', // T11823 + 'fiu-vro' => 'vro', // T31186 + 'roa-rup' => 'rup', // T17988 + 'zh-classical' => 'lzh', // T30443 + 'zh-min-nan' => 'nan', // T30442 + 'zh-yue' => 'yue', // T30441 + ]; + + /** + * Mapping of non-standard language codes used in MediaWiki to + * standardized BCP 47 codes. These are not deprecated (yet?): + * IANA may eventually recognize the subtag, in which case the `-x-` + * infix could be removed, or else we could rename the code in + * MediaWiki, in which case they'd move up to the above mapping + * of deprecated codes. + * + * As a rule, we preserve all distinctions made by MediaWiki + * internally. For example, `de-formal` becomes `de-x-formal` + * instead of just `de` because MediaWiki distinguishes `de-formal` + * from `de` (for example, for interface translations). Similarly, + * BCP 47 indicates that `kk-Cyrl` SHOULD not be used because it + * "typically does not add information", but in our case MediaWiki + * LanguageConverter distinguishes `kk` (render content in a mix of + * Kurdish variants) from `kk-Cyrl` (convert content to be uniformly + * Cyrillic). As the BCP 47 requirement is a SHOULD not a MUST, + * `kk-Cyrl` is a valid code, although some validators may emit + * a warning note. + * + * @var array Mapping from nonstandard codes to BCP 47 codes + * + * @since 1.32 + * @see https://meta.wikimedia.org/wiki/Special_language_codes + * @see https://phabricator.wikimedia.org/T125073 + */ + private static $nonstandardLanguageCodeMapping = [ + // All codes returned by Language::fetchLanguageNames() validated + // against IANA registry at + // https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry + // with help of validator at + // http://schneegans.de/lv/ + 'cbk-zam' => 'cbk', // T124657 + 'de-formal' => 'de-x-formal', + 'eml' => 'egl', // T36217 + 'en-rtl' => 'en-x-rtl', + 'es-formal' => 'es-x-formal', + 'hu-formal' => 'hu-x-formal', + 'map-bms' => 'jv-x-bms', // [[en:Banyumasan_dialect]] T125073 + 'mo' => 'ro-Cyrl-MD', // T125073 + 'nrm' => 'nrf', // [[en:Norman_language]] T25216 + 'nl-informal' => 'nl-x-informal', + 'roa-tara' => 'nap-x-tara', // [[en:Tarantino_dialect]] + 'simple' => 'en-simple', + 'sr-ec' => 'sr-Cyrl', // T117845 + 'sr-el' => 'sr-Latn', // T117845 + + // Although these next codes aren't *wrong* per se, including + // both the script and the country code helps compatibility with + // other BCP 47 users. Note that MW also uses `zh-Hans`/`zh-Hant`, + // without a country code, and those should be left alone. + // (See $variantfallbacks in LanguageZh.php for Hans/Hant id.) + 'zh-cn' => 'zh-Hans-CN', + 'zh-sg' => 'zh-Hans-SG', + 'zh-my' => 'zh-Hans-MY', + 'zh-tw' => 'zh-Hant-TW', + 'zh-hk' => 'zh-Hant-HK', + 'zh-mo' => 'zh-Hant-MO', ]; /** @@ -64,6 +127,29 @@ class LanguageCode { return self::$deprecatedLanguageCodeMapping; } + /** + * Returns a mapping of non-standard language codes used by + * (current and previous version of) MediaWiki, mapped to standard + * BCP 47 names. + * + * This array is exported to JavaScript to ensure + * mediawiki.language.bcp47 stays in sync with LanguageCode::bcp47(). + * + * @return string[] + * + * @since 1.32 + */ + public static function getNonstandardLanguageCodeMapping() { + $result = []; + foreach ( self::$deprecatedLanguageCodeMapping as $code => $ignore ) { + $result[$code] = self::bcp47( $code ); + } + foreach ( self::$nonstandardLanguageCodeMapping as $code => $ignore ) { + $result[$code] = self::bcp47( $code ); + } + return $result; + } + /** * Replace deprecated language codes that were used in previous * versions of MediaWiki to up-to-date, current language codes. @@ -87,11 +173,15 @@ class LanguageCode { * See mediawiki.language.bcp47 for the JavaScript implementation. * * @param string $code The language code. - * @return string The language code which complying with BCP 47 standards. + * @return string A language code complying with BCP 47 standards. * * @since 1.31 */ public static function bcp47( $code ) { + $code = self::replaceDeprecatedCodes( strtolower( $code ) ); + if ( isset( self::$nonstandardLanguageCodeMapping[$code] ) ) { + $code = self::$nonstandardLanguageCodeMapping[$code]; + } $codeSegment = explode( '-', $code ); $codeBCP = []; foreach ( $codeSegment as $segNo => $seg ) { diff --git a/languages/LanguageConverter.php b/languages/LanguageConverter.php index e51dca93ee..ea26c64dc7 100644 --- a/languages/LanguageConverter.php +++ b/languages/LanguageConverter.php @@ -175,11 +175,13 @@ class LanguageConverter { $req = $this->validateVariant( $wgDefaultLanguageVariant ); } + $req = $this->validateVariant( $req ); + // This function, unlike the other get*Variant functions, is // not memoized (i.e. there return value is not cached) since // new information might appear during processing after this // is first called. - if ( $this->validateVariant( $req ) ) { + if ( $req ) { return $req; } return $this->mMainLanguageCode; @@ -215,9 +217,25 @@ class LanguageConverter { * @return mixed Returns the variant if it is valid, null otherwise */ public function validateVariant( $variant = null ) { - if ( $variant !== null && in_array( $variant, $this->mVariants ) ) { + if ( $variant === null ) { + return null; + } + // Our internal variants are always lower-case; the variant we + // are validating may have mixed case. + $variant = LanguageCode::replaceDeprecatedCodes( strtolower( $variant ) ); + if ( in_array( $variant, $this->mVariants ) ) { return $variant; } + // Browsers are supposed to use BCP 47 standard in the + // Accept-Language header, but not all of our internal + // mediawiki variant codes are BCP 47. Map BCP 47 code + // to our internal code. + foreach ( $this->mVariants as $v ) { + // Case-insensitive match (BCP 47 is mixed case) + if ( strtolower( LanguageCode::bcp47( $v ) ) === $variant ) { + return $v; + } + } return null; } @@ -296,7 +314,7 @@ class LanguageConverter { return $this->mHeaderVariant; } - // see if some supported language variant is set in the + // See if some supported language variant is set in the // HTTP header. $languages = array_keys( $wgRequest->getAcceptLang() ); if ( empty( $languages ) ) { @@ -548,17 +566,18 @@ class LanguageConverter { $convTable = $convRule->getConvTable(); $action = $convRule->getRulesAction(); foreach ( $convTable as $variant => $pair ) { - if ( !$this->validateVariant( $variant ) ) { + $v = $this->validateVariant( $variant ); + if ( !$v ) { continue; } if ( $action == 'add' ) { // More efficient than array_merge(), about 2.5 times. foreach ( $pair as $from => $to ) { - $this->mTables[$variant]->setPair( $from, $to ); + $this->mTables[$v]->setPair( $from, $to ); } } elseif ( $action == 'remove' ) { - $this->mTables[$variant]->removeArray( $pair ); + $this->mTables[$v]->removeArray( $pair ); } } } diff --git a/languages/data/Names.php b/languages/data/Names.php index b038f08b1e..ec7c96e2c8 100644 --- a/languages/data/Names.php +++ b/languages/data/Names.php @@ -82,7 +82,7 @@ class Names { 'ba' => 'башҡортса', # Bashkir 'ban' => 'Basa Bali', # Balinese 'bar' => 'Boarisch', # Bavarian (Austro-Bavarian and South Tyrolean) - 'bat-smg' => 'žemaitėška', # Samogitian (deprecated code, 'sgs' in ISO 693-3 since 2010-06-30 ) + 'bat-smg' => 'žemaitėška', # Samogitian (deprecated code, 'sgs' in ISO 639-3 since 2010-06-30 ) 'bbc' => 'Batak Toba', # Batak Toba (falls back to bbc-latn) 'bbc-latn' => 'Batak Toba', # Batak Toba 'bcc' => 'جهلسری بلوچی', # Southern Balochi @@ -288,7 +288,7 @@ class Names { 'lzh' => '文言', # Literary Chinese, T10217 'lzz' => 'Lazuri', # Laz 'mai' => 'मैथिली', # Maithili - 'map-bms' => 'Basa Banyumasan', # Banyumasan + 'map-bms' => 'Basa Banyumasan', # Banyumasan ('jv-x-bms') 'mdf' => 'мокшень', # Moksha 'mg' => 'Malagasy', # Malagasy 'mh' => 'Ebon', # Marshallese @@ -300,7 +300,7 @@ class Names { 'mn' => 'монгол', # Halh Mongolian (Cyrillic) (ISO 639-3: khk) 'mni' => 'মেইতেই লোন্', # Manipuri/Meitei 'mnw' => 'ဘာသာ မန်', # Mon, T201583 - 'mo' => 'молдовеняскэ', # Moldovan, deprecated + 'mo' => 'молдовеняскэ', # Moldovan, deprecated (ISO 639-2: ro-Cyrl-MD) 'mr' => 'मराठी', # Marathi 'mrj' => 'кырык мары', # Hill Mari 'ms' => 'Bahasa Melayu', # Malay @@ -311,7 +311,7 @@ class Names { 'myv' => 'эрзянь', # Erzya 'mzn' => 'مازِرونی', # Mazanderani 'na' => 'Dorerin Naoero', # Nauruan - 'nah' => 'Nāhuatl', # Nahuatl (not in ISO 639-3) + 'nah' => 'Nāhuatl', # Nahuatl (added to ISO 639-3 on 2006-10-31) 'nan' => 'Bân-lâm-gú', # Min-nan, T10217 'nap' => 'Napulitano', # Neapolitan, T45793 'nb' => 'norsk bokmÃ¥l', # Norwegian (Bokmal) @@ -326,7 +326,7 @@ class Names { 'nn' => 'norsk nynorsk', # Norwegian (Nynorsk) 'no' => 'norsk', # Norwegian macro language (falls back to nb). 'nov' => 'Novial', # Novial - 'nrm' => 'Nouormand', # Norman + 'nrm' => 'Nouormand', # Norman (invalid code; 'nrf' in ISO 639 since 2014) 'nso' => 'Sesotho sa Leboa', # Northern Sotho 'nv' => 'Diné bizaad', # Navajo 'ny' => 'Chi-Chewa', # Chichewa @@ -362,8 +362,8 @@ class Names { 'rmy' => 'Romani', # Vlax Romany 'rn' => 'Kirundi', # Rundi/Kirundi/Urundi 'ro' => 'română', # Romanian - 'roa-rup' => 'armãneashti', # Aromanian (deprecated code, 'rup' exists in ISO 693-3) - 'roa-tara' => 'tarandíne', # Tarantino + 'roa-rup' => 'armãneashti', # Aromanian (deprecated code, 'rup' exists in ISO 639-3) + 'roa-tara' => 'tarandíne', # Tarantino ('nap-x-tara') 'ru' => 'русский', # Russian 'rue' => 'русиньскый', # Rusyn 'rup' => 'armãneashti', # Aromanian @@ -439,7 +439,7 @@ class Names { 'tt-cyrl' => 'татарча', # Tatar (Cyrillic script) (default) 'tt-latn' => 'tatarça', # Tatar (Latin script) 'tum' => 'chiTumbuka', # Tumbuka - 'tw' => 'Twi', # Twi, (FIXME!) + 'tw' => 'Twi', # Twi 'ty' => 'reo tahiti', # Tahitian 'tyv' => 'тыва дыл', # Tyvan 'tzm' => 'ⵜⴰⵎⴰⵣⵉⵖⵜ', # Tamazight diff --git a/languages/i18n/be-tarask.json b/languages/i18n/be-tarask.json index f72a8fe793..db8f0f48d9 100644 --- a/languages/i18n/be-tarask.json +++ b/languages/i18n/be-tarask.json @@ -1700,7 +1700,7 @@ "uploadstash-errclear": "Не атрымалася ачысьціць файлы.", "uploadstash-refresh": "Абнавіць сьпіс файлаў", "uploadstash-thumbnail": "прагляд мініятуры", - "uploadstash-exception": "Не магу захаваць загрузку ў сховішчы ($1): «$2».", + "uploadstash-exception": "Не атрымалася захаваць загрузку ў хованку ($1): «$2».", "uploadstash-bad-path": "Шлях не існуе.", "uploadstash-bad-path-invalid": "Шлях не зьяўляецца слушным.", "uploadstash-bad-path-unknown-type": "Невядомы тып «$1».", @@ -2582,6 +2582,7 @@ "movepage-moved": "'''Старонка «$1» была перанесеная ў «$2»'''", "movepage-moved-redirect": "Перанакіраваньне было створана.", "movepage-moved-noredirect": "Перанакіраваньне не было створанае.", + "movepage-delete-first": "Мэтавая старонка мае зашмат вэрсіяў, каб выдаліць яе пры пераносе. Калі ласка, спачатку выдаліце старонку ўручную, а потым паспрабуйце яшчэ раз.", "articleexists": "Старонка з такой назвай ужо існуе, альбо абраная Вамі назва недапушчальная. Калі ласка, абярыце іншую назву.", "cantmove-titleprotected": "Немагчыма перанесьці старонку, таму што новая назва знаходзіцца ў сьпісе забароненых", "movetalk": "Перанесьці таксама старонку абмеркаваньня", diff --git a/languages/i18n/fr.json b/languages/i18n/fr.json index f10a2cba07..07b43d0294 100644 --- a/languages/i18n/fr.json +++ b/languages/i18n/fr.json @@ -488,7 +488,7 @@ "badarticleerror": "Cette action ne peut pas être effectuée sur cette page.", "cannotdelete": "Impossible de supprimer la page ou le fichier « $1 ».\nLa suppression a peut-être déjà été effectuée par quelqu’un d’autre.", "cannotdelete-title": "Impossible de supprimer la page « $1 »", - "delete-scheduled": "La page « $1 » st programmée pour être supprimée.\nVeuillez patienter.", + "delete-scheduled": "La page « $1 » est programmée pour être supprimée.\nVeuillez patienter.", "delete-hook-aborted": "Suppression annulée par une extension.\nAucune explication n’a été fournie.", "no-null-revision": "Impossible de créer une nouvelle révision vide pour la page « $1 »", "badtitle": "Mauvais titre", @@ -2753,7 +2753,7 @@ "movepage-moved": "« $1 » a été renommée en « $2 »", "movepage-moved-redirect": "Une redirection depuis l’ancien nom a été créée.", "movepage-moved-noredirect": "La création d’une redirection depuis l’ancien nom a été annulée.", - "movepage-delete-first": "La page cible a trop de révisions à supprimer en tant que faisant partie du déplacement d’une page.\nVeuillez d’abord supprimer la page manuellement, puis réessayer.", + "movepage-delete-first": "La page cible a trop de révisions à supprimer pour le déplacement de page. Veuillez d’abord supprimer la page manuellement, puis réessayer.", "articleexists": "Il existe déjà une page portant ce titre, ou le titre que vous avez choisi n'est pas correct.\nVeuillez en choisir un autre.", "cantmove-titleprotected": "Vous ne pouvez pas déplacer une page vers cet emplacement car la création de page avec ce nouveau titre a été protégée.", "movetalk": "Renommer aussi la page de discussion associée", diff --git a/languages/i18n/kjp.json b/languages/i18n/kjp.json index e460537e40..09a66acb19 100644 --- a/languages/i18n/kjp.json +++ b/languages/i18n/kjp.json @@ -184,7 +184,7 @@ "page-atom-feed": "Atom feed $1 ဍူ", "red-link-title": "$1 (လိက်မေံ လ်ုအ်ှ​ဍေၜး)", "nstab-main": "လက်မေံသး", - "nstab-user": "ဆ်ုသုဲးက်ုဆာ လက်မေံ", + "nstab-user": "ဆ်ုသုံႋက်ုဆာႋ လိက်မေံၜၠာ်", "nstab-special": "လိက်မေံခေါဟ်", "nstab-project": "ပ်ုရောဴဂျက်လိက်မေံၜၠါ်", "nstab-image": "ဖိုင်", diff --git a/languages/i18n/lb.json b/languages/i18n/lb.json index a7caa16fe9..4762819fbf 100644 --- a/languages/i18n/lb.json +++ b/languages/i18n/lb.json @@ -3464,6 +3464,8 @@ "revdelete-unrestricted": "Limitatioune fir Administrateuren opgehuewen", "logentry-block-block": "$1 {{GENDER:$2|huet}} {{GENDER:$4|$3}} fir eng Zäit vu(n) $5 $6 gespaart", "logentry-block-unblock": "$1 {{GENDER:$2|huet}} d'Spär vum {{GENDER:$4|$3}} opgehuewen", + "logentry-block-reblock": "$1 {{GENDER:$2|huet}} d'Spärastellunge fir {{GENDER:$4|$3}} mat enger Spärdauer vu(n) $5 $6 geännert", + "logentry-suppress-reblock": "$1 {{GENDER:$2|huet}} d'Spärastellunge fir {{GENDER:$4|$3}} mat enger Spärdauer vu(n) $5 $6 geännert", "logentry-import-upload": "$1 {{GENDER:$2|huet}} $3 duerch Eropluede vun engem Fichier importéiert", "logentry-import-interwiki": "$1 huet $3 vun enger anerer Wiki {{GENDER:$2|importéiert}}", "logentry-import-interwiki-details": "$1 {{GENDER:$2|huet}} $3 vu(n) $5 importéiert ({{PLURAL:$4|Eng Versioun|$4 Versiounen}})", diff --git a/languages/i18n/mk.json b/languages/i18n/mk.json index 9080dbea1e..eddfffc5ed 100644 --- a/languages/i18n/mk.json +++ b/languages/i18n/mk.json @@ -1323,7 +1323,7 @@ "action-editmyprivateinfo": "уредување на вашите лични податоци", "action-editcontentmodel": "уредување на содржинскиот модел на страница", "action-managechangetags": "создавање или (де)активирање на ознаки", - "action-applychangetags": "ставање на ознаки заедно со напревените промени", + "action-applychangetags": "ставање на ознаки заедно со вашите промени", "action-changetags": "додавање и отстранување на произволни ознаки во поединечни преработки и дневнички записи", "action-deletechangetags": "бришење ознаки од базата", "action-purge": "превчитување на оваа страница", diff --git a/languages/i18n/nn.json b/languages/i18n/nn.json index b3fc56c9e1..e097d1d5aa 100644 --- a/languages/i18n/nn.json +++ b/languages/i18n/nn.json @@ -1331,7 +1331,9 @@ "recentchangeslinked-page": "Sidenamn:", "recentchangeslinked-to": "Vis endringar pÃ¥ sider som lenkjar til den gitte sida i staden", "recentchanges-page-added-to-category": "[[:$1]] vart lagd til kategorien", + "recentchanges-page-added-to-category-bundled": "[[:$1]] lagt til i kategorien; [[Special:WhatLinksHere/$1|denne sida er inkludert i andre sider]]", "recentchanges-page-removed-from-category": "[[:$1]] fjerna frÃ¥ kategori", + "recentchanges-page-removed-from-category-bundled": "[[:$1]] fjernat frÃ¥ kategori, [[Special:WhatLinksHere/$1|denne sida er inkludert i andre sider]]", "upload": "Last opp fil", "uploadbtn": "Last opp fil", "reuploaddesc": "Attende til opplastingsskjemaet.", diff --git a/languages/i18n/pt-br.json b/languages/i18n/pt-br.json index a27be6ce04..1401c241c3 100644 --- a/languages/i18n/pt-br.json +++ b/languages/i18n/pt-br.json @@ -439,6 +439,7 @@ "badarticleerror": "Esta ação não pode ser realizada nesta página.", "cannotdelete": "Não foi possível eliminar a página ou arquivo $1.\nÉ possível que ele já tenha sido eliminado por outra pessoa.", "cannotdelete-title": "Não é possível eliminar a página \"$1\"", + "delete-scheduled": "A página \"$1\" está agendada para eliminação.\nAguarde a mesma, por favor.", "delete-hook-aborted": "A eliminação foi cancelada por um \"hook\".\nNão foi dada nenhuma explicação.", "no-null-revision": "Não foi possível criar nova revisão nula para a página \"$1\"", "badtitle": "Título inválido", @@ -2711,6 +2712,7 @@ "movepage-moved": "'''\"$1\" foi movida para \"$2\"'''", "movepage-moved-redirect": "Um redirecionamento foi criado.", "movepage-moved-noredirect": "A criação de um redirecionamento foi suprimida.", + "movepage-delete-first": "A página de destino tem muitas revisões para excluir como parte de uma movimentação de página. Primeiro, exclua a página manualmente e tente novamente.", "articleexists": "Uma página com este título já existe, ou o título que escolheu é inválido.\nPor favor, escolha outro nome.", "cantmove-titleprotected": "Você não pode mover uma página para tal denominação uma vez que o novo título se encontra protegido contra criação", "movetalk": "Mover também a página de discussão associada", diff --git a/languages/i18n/pt.json b/languages/i18n/pt.json index 1f9041a784..f3ee41dfea 100644 --- a/languages/i18n/pt.json +++ b/languages/i18n/pt.json @@ -401,6 +401,7 @@ "badarticleerror": "Esta operação não pode ser realizada nesta página.", "cannotdelete": "Não foi possível eliminar a página ou ficheiro \"$1\".\nPode já ter sido eliminado por outro utilizador.", "cannotdelete-title": "Não é possível eliminar a página \"$1\"", + "delete-scheduled": "A página \"$1\" está agendada para eliminação.\nAguarde a mesma, por favor.", "delete-hook-aborted": "A eliminação foi cancelada por um \"hook\".\nNão foi dada nenhuma explicação.", "no-null-revision": "Não foi possível criar uma nova revisão nula para a página \"$1\"", "badtitle": "Título inválido", @@ -2659,6 +2660,7 @@ "movepage-moved": "\"$1\" foi movida para \"$2\"", "movepage-moved-redirect": "Foi criado um redirecionamento.", "movepage-moved-noredirect": "A criação de um redirecionamento foi suprimida.", + "movepage-delete-first": "A página de destino tem demasiadas revisões para apagá-las como parte de uma movimentação de página. Elimine-a primeiro manualmente e depois tente novamente, por favor.", "articleexists": "Ou já existe uma página com este nome ou o nome que escolheu é inválido.\nEscolha outro nome, por favor.", "cantmove-titleprotected": "Não pode mover uma página para esse destino, porque o novo título foi protegido para evitar a sua criação", "movetalk": "Mover também a página de discussão associada", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 2ca765fece..a17cfca049 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -3417,8 +3417,8 @@ "variantname-gan-hans": "{{Optional}}\n\nVariant option for wikis with variants conversion enabled.", "variantname-gan-hant": "{{Optional}}\n\nVariant option for wikis with variants conversion enabled.", "variantname-gan": "{{Optional}}\n\nVariant option for wikis with variants conversion enabled.", - "variantname-sr-ec": "{{optional}}\nVariant Option for wikis with variants conversion enabled.\n\nNote that sr-ec is not a conforming BCP47 language tag. Wikis should be migrated by:\n* allowing it only as a legacy alias of the preferred tag sr-cyrl (possibly insert a tracking category in templates as long as they must support the legacy tag),\n* making the new tag the default to look first, before looking for the old tag,\n* moving the translations to the new code by renaming them,\n* checking links in source pages still using the legacy tag to change it to the new tag,\n* possibly cleanup the redirect pages.", - "variantname-sr-el": "{{optional}}\nVariant Option for wikis with variants conversion enabled.\n\nNote that sr-el is not a conforming BCP47 language tag. Wikis should be migrated by:\n* allowing it only as a legacy alias of the preferred tag sr-latn (possibly insert a tracking category in templates as long as they must support the legacy tag),\n* making the new tag the default to look first, before looking for the old tag,\n* moving the translations to the new code by renaming them,\n* checking links in source pages still using the legacy tag to change it to the new tag,\n* possibly cleanup the redirect pages.", + "variantname-sr-ec": "{{optional}}\nVariant Option for wikis with variants conversion enabled.\n\nNote that sr-ec is not a conforming BCP 47 language tag. Wikis should be migrated by:\n* allowing it only as a legacy alias of the preferred tag sr-cyrl (possibly insert a tracking category in templates as long as they must support the legacy tag),\n* making the new tag the default to look first, before looking for the old tag,\n* moving the translations to the new code by renaming them,\n* checking links in source pages still using the legacy tag to change it to the new tag,\n* possibly cleanup the redirect pages.", + "variantname-sr-el": "{{optional}}\nVariant Option for wikis with variants conversion enabled.\n\nNote that sr-el is not a conforming BCP 47 language tag. Wikis should be migrated by:\n* allowing it only as a legacy alias of the preferred tag sr-latn (possibly insert a tracking category in templates as long as they must support the legacy tag),\n* making the new tag the default to look first, before looking for the old tag,\n* moving the translations to the new code by renaming them,\n* checking links in source pages still using the legacy tag to change it to the new tag,\n* possibly cleanup the redirect pages.", "variantname-sr": "{{optional}}\nVariant Option for wikis with variants conversion enabled.", "variantname-kk-kz": "{{optional}}\nVariant Option for wikis with variants conversion enabled.", "variantname-kk-tr": "{{optional}}\nVariant Option for wikis with variants conversion enabled.", diff --git a/languages/i18n/ru.json b/languages/i18n/ru.json index a3b042a846..2f5e7cb68a 100644 --- a/languages/i18n/ru.json +++ b/languages/i18n/ru.json @@ -1443,7 +1443,7 @@ "action-editmyprivateinfo": "редактирование вашей частной информации", "action-editcontentmodel": "редактирование контентной модели страницы", "action-managechangetags": "создание и (де)активацию меток", - "action-applychangetags": " применять теги наряду с Вашими изменениями", + "action-applychangetags": "применение меток вместе с Вашими изменениями", "action-changetags": "добавление и удаление произвольных меток на отдельных изменениях и записях в журнале", "action-deletechangetags": "удаление меток из базы данных", "action-purge": "очистку кэша этой страницы", diff --git a/languages/i18n/sr-ec.json b/languages/i18n/sr-ec.json index 8784722980..2c9e026fe9 100644 --- a/languages/i18n/sr-ec.json +++ b/languages/i18n/sr-ec.json @@ -441,7 +441,7 @@ "externaldberror": "Дошло је до грешке при потврди идентитета базе података или вам није дозвољено да ажурирате свој спољни налог.", "login": "Пријава", "login-security": "Потврда вашег индентитета", - "nav-login-createaccount": "Пријави ме / отвори налог", + "nav-login-createaccount": "Пријава / регистрација", "logout": "Одјава", "userlogout": "Одјава", "notloggedin": "Нисте пријављени", diff --git a/maintenance/update.php b/maintenance/update.php index c780b6ae6b..2a1feb4603 100755 --- a/maintenance/update.php +++ b/maintenance/update.php @@ -85,7 +85,7 @@ class UpdateMediaWiki extends Maintenance { } function execute() { - global $wgVersion, $wgLang, $wgAllowSchemaUpdates; + global $wgVersion, $wgLang, $wgAllowSchemaUpdates, $wgMessagesDirs; if ( !$wgAllowSchemaUpdates && !( $this->hasOption( 'force' ) @@ -111,6 +111,9 @@ class UpdateMediaWiki extends Maintenance { } } + // T206765: We need to load the installer i18n files as some of errors come installer/updater code + $wgMessagesDirs['MediawikiInstaller'] = dirname( __DIR__ ) . '/includes/installer/i18n'; + $lang = Language::factory( 'en' ); // Set global language to ensure localised errors are in English (T22633) RequestContext::getMain()->setLanguage( $lang ); diff --git a/resources/src/mediawiki.language/mediawiki.language.init.js b/resources/src/mediawiki.language/mediawiki.language.init.js index 33f8fd7d93..dbd7cb92c4 100644 --- a/resources/src/mediawiki.language/mediawiki.language.init.js +++ b/resources/src/mediawiki.language/mediawiki.language.init.js @@ -37,6 +37,8 @@ * - `pluralRules` * - `digitGroupingPattern` * - `fallbackLanguages` + * - `bcp47Map` + * - `languageNames` * * @property */ diff --git a/resources/src/mediawiki.language/mediawiki.language.js b/resources/src/mediawiki.language/mediawiki.language.js index dfb7112870..8fed6954f5 100644 --- a/resources/src/mediawiki.language/mediawiki.language.js +++ b/resources/src/mediawiki.language/mediawiki.language.js @@ -163,18 +163,27 @@ }, /** - * Formats language tags according the BCP47 standard. + * Formats language tags according the BCP 47 standard. * See LanguageCode::bcp47 for the PHP implementation. * * @param {string} languageTag Well-formed language tag * @return {string} */ bcp47: function ( languageTag ) { - var formatted, + var bcp47Map, + formatted, + segments, isFirstSegment = true, - isPrivate = false, - segments = languageTag.split( '-' ); + isPrivate = false; + languageTag = languageTag.toLowerCase(); + + bcp47Map = mw.language.getData( mw.config.get( 'wgUserLanguage' ), 'bcp47Map' ); + if ( bcp47Map && Object.prototype.hasOwnProperty.call( bcp47Map, languageTag ) ) { + languageTag = bcp47Map[ languageTag ]; + } + + segments = languageTag.split( '-' ); formatted = segments.map( function ( segment ) { var newSegment; diff --git a/tests/phpunit/MediaWikiTestCase.php b/tests/phpunit/MediaWikiTestCase.php index feab0df451..287d28c3b9 100644 --- a/tests/phpunit/MediaWikiTestCase.php +++ b/tests/phpunit/MediaWikiTestCase.php @@ -100,6 +100,14 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase { */ private $mwGlobalsToUnset = []; + /** + * Holds original values of ini settings to be restored + * in tearDown(). + * @see setIniSettings() + * @var array + */ + private $iniSettings = []; + /** * Holds original loggers which have been replaced by setLogger() * @var LoggerInterface[] @@ -573,6 +581,9 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase { foreach ( $this->mwGlobalsToUnset as $value ) { unset( $GLOBALS[$value] ); } + foreach ( $this->iniSettings as $name => $value ) { + ini_set( $name, $value ); + } if ( array_key_exists( 'wgExtraNamespaces', $this->mwGlobals ) || in_array( 'wgExtraNamespaces', $this->mwGlobalsToUnset ) @@ -722,6 +733,18 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase { } } + /** + * Set an ini setting for the duration of the test + * @param string $name Name of the setting + * @param string $value Value to set + * @since 1.32 + */ + protected function setIniSetting( $name, $value ) { + $original = ini_get( $name ); + $this->iniSettings[$name] = $original; + ini_set( $name, $value ); + } + /** * Must be called whenever namespaces are changed, e.g., $wgExtraNamespaces is altered. * Otherwise old namespace data will lurk and cause bugs. diff --git a/tests/phpunit/includes/HooksTest.php b/tests/phpunit/includes/HooksTest.php index c1efc7ffec..c66b712b83 100644 --- a/tests/phpunit/includes/HooksTest.php +++ b/tests/phpunit/includes/HooksTest.php @@ -54,23 +54,6 @@ class HooksTest extends MediaWikiTestCase { ]; } - /** - * @dataProvider provideHooks - * @covers ::wfRunHooks - */ - public function testOldStyleHooks( $msg, array $hook, $expectedFoo, $expectedBar ) { - global $wgHooks; - - $this->hideDeprecated( 'wfRunHooks' ); - $foo = $bar = 'original'; - - $wgHooks['MediaWikiHooksTest001'][] = $hook; - wfRunHooks( 'MediaWikiHooksTest001', [ &$foo, &$bar ] ); - - $this->assertSame( $expectedFoo, $foo, $msg ); - $this->assertSame( $expectedBar, $bar, $msg ); - } - /** * @dataProvider provideHooks * @covers Hooks::register diff --git a/tests/phpunit/includes/HtmlTest.php b/tests/phpunit/includes/HtmlTest.php index 5cc59b3978..156f702c07 100644 --- a/tests/phpunit/includes/HtmlTest.php +++ b/tests/phpunit/includes/HtmlTest.php @@ -10,12 +10,12 @@ class HtmlTest extends MediaWikiTestCase { 'wgUseMediaWikiUIEverywhere' => false, ] ); - $langObj = Language::factory( 'en' ); + $contLangObj = Language::factory( 'en' ); // Hardcode namespaces during test runs, // so that html output based on existing namespaces // can be properly evaluated. - $langObj->setNamespaces( [ + $contLangObj->setNamespaces( [ -2 => 'Media', -1 => 'Special', 0 => '', @@ -35,8 +35,33 @@ class HtmlTest extends MediaWikiTestCase { 100 => 'Custom', 101 => 'Custom_talk', ] ); - $this->setUserLang( $langObj ); - $this->setContentLang( $langObj ); + $this->setContentLang( $contLangObj ); + + $userLangObj = Language::factory( 'es' ); + $userLangObj->setNamespaces( [ + -2 => "Medio", + -1 => "Especial", + 0 => "", + 1 => "Discusión", + 2 => "Usuario", + 3 => "Usuario discusión", + 4 => "Wiki", + 5 => "Wiki discusión", + 6 => "Archivo", + 7 => "Archivo discusión", + 8 => "MediaWiki", + 9 => "MediaWiki discusión", + 10 => "Plantilla", + 11 => "Plantilla discusión", + 12 => "Ayuda", + 13 => "Ayuda discusión", + 14 => "Categoría", + 15 => "Categoría discusión", + 100 => "Personalizado", + 101 => "Personalizado discusión", + ] ); + $this->setUserLang( $userLangObj ); + $this->restoreWarnings = false; } @@ -322,7 +347,7 @@ class HtmlTest extends MediaWikiTestCase { public function testNamespaceSelector() { $this->assertEquals( '' . "\n" . - '' . "\n" . - '' . "\n" . + '' . "\n" . + '' . "\n" . '' . "\n" . '' . "\n" . '' . "\n" . @@ -374,7 +399,7 @@ class HtmlTest extends MediaWikiTestCase { $this->assertEquals( '' . "\u{00A0}" . '' . "\n" . - '' . "\n" . + '' . "\n" . '' . "\n" . '' . "\n" . '' . "\n" . diff --git a/tests/phpunit/includes/OutputPageTest.php b/tests/phpunit/includes/OutputPageTest.php index 74c17367a3..53e6f464c9 100644 --- a/tests/phpunit/includes/OutputPageTest.php +++ b/tests/phpunit/includes/OutputPageTest.php @@ -255,6 +255,7 @@ class OutputPageTest extends MediaWikiTestCase { /** * @covers OutputPage::getHeadItemsArray * @covers OutputPage::addParserOutputMetadata + * @covers OutputPage::addParserOutput */ public function testHeadItemsParserOutput() { $op = $this->newInstance(); @@ -264,7 +265,7 @@ class OutputPageTest extends MediaWikiTestCase { [ 'c' => '&', 'e' => 'f', 'a' => 'q' ] ); $op->addParserOutputMetadata( $stubPO2 ); $stubPO3 = $this->createParserOutputStub( 'getHeadItems', [ 'e' => 'g' ] ); - $op->addParserOutputMetadata( $stubPO3 ); + $op->addParserOutput( $stubPO3 ); $stubPO4 = $this->createParserOutputStub( 'getHeadItems', [ 'x' ] ); $op->addParserOutputMetadata( $stubPO4 ); @@ -756,33 +757,39 @@ class OutputPageTest extends MediaWikiTestCase { /** * @covers OutputPage::showNewSectionLink * @covers OutputPage::addParserOutputMetadata + * @covers OutputPage::addParserOutput */ public function testShowNewSectionLink() { $op = $this->newInstance(); $this->assertFalse( $op->showNewSectionLink() ); - $po = new ParserOutput(); - $po->setNewSection( true ); - $op->addParserOutputMetadata( $po ); - + $pOut1 = $this->createParserOutputStub( 'getNewSection', true ); + $op->addParserOutputMetadata( $pOut1 ); $this->assertTrue( $op->showNewSectionLink() ); + + $pOut2 = $this->createParserOutputStub( 'getNewSection', false ); + $op->addParserOutput( $pOut2 ); + $this->assertFalse( $op->showNewSectionLink() ); } /** * @covers OutputPage::forceHideNewSectionLink * @covers OutputPage::addParserOutputMetadata + * @covers OutputPage::addParserOutput */ public function testForceHideNewSectionLink() { $op = $this->newInstance(); $this->assertFalse( $op->forceHideNewSectionLink() ); - $po = new ParserOutput(); - $po->hideNewSection( true ); - $op->addParserOutputMetadata( $po ); - + $pOut1 = $this->createParserOutputStub( 'getHideNewSection', true ); + $op->addParserOutputMetadata( $pOut1 ); $this->assertTrue( $op->forceHideNewSectionLink() ); + + $pOut2 = $this->createParserOutputStub( 'getHideNewSection', false ); + $op->addParserOutput( $pOut2 ); + $this->assertFalse( $op->forceHideNewSectionLink() ); } /** @@ -868,6 +875,7 @@ class OutputPageTest extends MediaWikiTestCase { * @covers OutputPage::setLanguageLinks * @covers OutputPage::getLanguageLinks * @covers OutputPage::addParserOutputMetadata + * @covers OutputPage::addParserOutput */ function testLanguageLinks() { $op = $this->newInstance(); @@ -882,10 +890,13 @@ class OutputPageTest extends MediaWikiTestCase { $op->setLanguageLinks( [ 'pt:E' ] ); $this->assertSame( [ 'pt:E' ], $op->getLanguageLinks() ); - $po = new ParserOutput(); - $po->setLanguageLinks( [ 'he:F', 'ar:G' ] ); - $op->addParserOutputMetadata( $po ); + $pOut1 = $this->createParserOutputStub( 'getLanguageLinks', [ 'he:F', 'ar:G' ] ); + $op->addParserOutputMetadata( $pOut1 ); $this->assertSame( [ 'pt:E', 'he:F', 'ar:G' ], $op->getLanguageLinks() ); + + $pOut2 = $this->createParserOutputStub( 'getLanguageLinks', [ 'pt:H' ] ); + $op->addParserOutput( $pOut2 ); + $this->assertSame( [ 'pt:E', 'he:F', 'ar:G', 'pt:H' ], $op->getLanguageLinks() ); } // @todo Are these category links tests too abstract and complicated for what they test? Would @@ -981,6 +992,7 @@ class OutputPageTest extends MediaWikiTestCase { * @dataProvider provideGetCategories * * @covers OutputPage::addParserOutputMetadata + * @covers OutputPage::addParserOutput * @covers OutputPage::getCategories * @covers OutputPage::getCategoryLinks */ @@ -995,7 +1007,12 @@ class OutputPageTest extends MediaWikiTestCase { $stubPO = $this->createParserOutputStub( 'getCategories', $args ); - $op->addParserOutputMetadata( $stubPO ); + // addParserOutput and addParserOutputMetadata should behave identically for us, so + // alternate to get coverage for both without adding extra tests + static $idx = 0; + $idx++; + $method = [ 'addParserOutputMetadata', 'addParserOutput' ][$idx % 2]; + $op->$method( $stubPO ); $this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden ); $this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden ); @@ -1133,6 +1150,7 @@ class OutputPageTest extends MediaWikiTestCase { * @covers OutputPage::setIndicators * @covers OutputPage::getIndicators * @covers OutputPage::addParserOutputMetadata + * @covers OutputPage::addParserOutput */ public function testIndicators() { $op = $this->newInstance(); @@ -1149,11 +1167,17 @@ class OutputPageTest extends MediaWikiTestCase { $op->setIndicators( [ 'c' => 'z', 'a' => 'w' ] ); $this->assertSame( [ 'a' => 'w', 'b' => 'x', 'c' => 'z' ], $op->getIndicators() ); - // Test with ParserOutput - $stubPO = $this->createParserOutputStub( 'getIndicators', [ 'c' => 'u', 'd' => 'v' ] ); - $op->addParserOutputMetadata( $stubPO ); + // Test with addParserOutputMetadata + $pOut1 = $this->createParserOutputStub( 'getIndicators', [ 'c' => 'u', 'd' => 'v' ] ); + $op->addParserOutputMetadata( $pOut1 ); $this->assertSame( [ 'a' => 'w', 'b' => 'x', 'c' => 'u', 'd' => 'v' ], $op->getIndicators() ); + + // Test with addParserOutput + $pOut2 = $this->createParserOutputStub( 'getIndicators', [ 'a' => '!!!' ] ); + $op->addParserOutput( $pOut2 ); + $this->assertSame( [ 'a' => '!!!', 'b' => 'x', 'c' => 'u', 'd' => 'v' ], + $op->getIndicators() ); } /** @@ -1276,9 +1300,20 @@ class OutputPageTest extends MediaWikiTestCase { $this->assertNull( $op->getFileVersion() ); } - private function createParserOutputStub( $method = '', $retVal = [] ) { + /** + * Call either with arguments $methodName, $returnValue; or an array + * [ $methodName => $returnValue, $methodName => $returnValue, ... ] + */ + private function createParserOutputStub( ...$args ) { + if ( count( $args ) === 0 ) { + $retVals = []; + } elseif ( count( $args ) === 1 ) { + $retVals = $args[0]; + } elseif ( count( $args ) === 2 ) { + $retVals = [ $args[0] => $args[1] ]; + } $pOut = $this->getMock( ParserOutput::class ); - if ( $method !== '' ) { + foreach ( $retVals as $method => $retVal ) { $pOut->method( $method )->willReturn( $retVal ); } @@ -1302,6 +1337,7 @@ class OutputPageTest extends MediaWikiTestCase { /** * @covers OutputPage::getTemplateIds * @covers OutputPage::addParserOutputMetadata + * @covers OutputPage::addParserOutput */ public function testTemplateIds() { $op = $this->newInstance(); @@ -1337,7 +1373,7 @@ class OutputPageTest extends MediaWikiTestCase { NS_PROJECT => [ 'F' => 5678 ], ]; - $op->addParserOutputMetadata( $stubPO2 ); + $op->addParserOutput( $stubPO2 ); $this->assertSame( $finalIds, $op->getTemplateIds() ); // Test merging with an empty set of id's @@ -1348,6 +1384,7 @@ class OutputPageTest extends MediaWikiTestCase { /** * @covers OutputPage::getFileSearchOptions * @covers OutputPage::addParserOutputMetadata + * @covers OutputPage::addParserOutput */ public function testFileSearchOptions() { $op = $this->newInstance(); @@ -1370,7 +1407,7 @@ class OutputPageTest extends MediaWikiTestCase { $stubPO1 = $this->createParserOutputStub( 'getFileSearchOptions', $files1 ); - $op->addParserOutputMetadata( $stubPO1 ); + $op->addParserOutput( $stubPO1 ); $this->assertSame( $files1, $op->getFileSearchOptions() ); // Test merging with a second set of files @@ -1385,7 +1422,7 @@ class OutputPageTest extends MediaWikiTestCase { $this->assertSame( array_merge( $files1, $files2 ), $op->getFileSearchOptions() ); // Test merging with an empty set of files - $op->addParserOutputMetadata( $stubPOEmpty ); + $op->addParserOutput( $stubPOEmpty ); $this->assertSame( array_merge( $files1, $files2 ), $op->getFileSearchOptions() ); } @@ -1619,6 +1656,7 @@ class OutputPageTest extends MediaWikiTestCase { /** * @covers OutputPage::addParserOutputMetadata + * @covers OutputPage::addParserOutput */ public function testNoGallery() { $op = $this->newInstance(); @@ -1629,29 +1667,386 @@ class OutputPageTest extends MediaWikiTestCase { $this->assertTrue( $op->mNoGallery ); $stubPO2 = $this->createParserOutputStub( 'getNoGallery', false ); - $op->addParserOutputMetadata( $stubPO2 ); + $op->addParserOutput( $stubPO2 ); $this->assertFalse( $op->mNoGallery ); } + private static $parserOutputHookCalled; + + /** + * @covers OutputPage::addParserOutputMetadata + */ + public function testParserOutputHooks() { + $op = $this->newInstance(); + $pOut = $this->createParserOutputStub( 'getOutputHooks', [ + [ 'myhook', 'banana' ], + [ 'yourhook', 'kumquat' ], + [ 'theirhook', 'hippopotamus' ], + ] ); + + self::$parserOutputHookCalled = []; + + $this->setMwGlobals( 'wgParserOutputHooks', [ + 'myhook' => function ( OutputPage $innerOp, ParserOutput $innerPOut, $data ) + use ( $op, $pOut ) { + $this->assertSame( $op, $innerOp ); + $this->assertSame( $pOut, $innerPOut ); + $this->assertSame( 'banana', $data ); + self::$parserOutputHookCalled[] = 'closure'; + }, + 'yourhook' => [ $this, 'parserOutputHookCallback' ], + 'theirhook' => [ __CLASS__, 'parserOutputHookCallbackStatic' ], + 'uncalled' => function () { + $this->assertTrue( false ); + }, + ] ); + + $op->addParserOutputMetadata( $pOut ); + + $this->assertSame( [ 'closure', 'callback', 'static' ], self::$parserOutputHookCalled ); + } + + public function parserOutputHookCallback( + OutputPage $op, ParserOutput $pOut, $data + ) { + $this->assertSame( 'kumquat', $data ); + + self::$parserOutputHookCalled[] = 'callback'; + } + + public static function parserOutputHookCallbackStatic( + OutputPage $op, ParserOutput $pOut, $data + ) { + // All the assert methods are actually static, who knew! + self::assertSame( 'hippopotamus', $data ); + + self::$parserOutputHookCalled[] = 'static'; + } + // @todo Make sure to test the following in addParserOutputMetadata() as well when we add tests // for them: - // * enableClientCache() // * addModules() // * addModuleScripts() // * addModuleStyles() // * addJsConfigVars() - // * preventClickJacking() + // * enableOOUI() // Otherwise those lines of addParserOutputMetadata() will be reported as covered, but we won't // be testing they actually work. + /** + * @covers OutputPage::addParserOutputText + */ + public function testAddParserOutputText() { + $op = $this->newInstance(); + $this->assertSame( '', $op->getHTML() ); + + $pOut = $this->createParserOutputStub( 'getText', '' ); + + $op->addParserOutputMetadata( $pOut ); + $this->assertSame( '', $op->getHTML() ); + + $op->addParserOutputText( $pOut ); + $this->assertSame( '', $op->getHTML() ); + } + + /** + * @covers OutputPage::addParserOutput + */ + public function testAddParserOutput() { + $op = $this->newInstance(); + $this->assertSame( '', $op->getHTML() ); + $this->assertFalse( $op->showNewSectionLink() ); + + $pOut = $this->createParserOutputStub( [ + 'getText' => '', + 'getNewSection' => true, + ] ); + + $op->addParserOutput( $pOut ); + $this->assertSame( '', $op->getHTML() ); + $this->assertTrue( $op->showNewSectionLink() ); + } + + /** + * @covers OutputPage::addTemplate + */ + public function testAddTemplate() { + $template = $this->getMock( QuickTemplate::class ); + $template->method( 'getHTML' )->willReturn( '&def;' ); + + $op = $this->newInstance(); + $op->addTemplate( $template ); + + $this->assertSame( '&def;', $op->getHTML() ); + } + + /** + * @dataProvider provideParse + * @covers OutputPage::parse + * @param array $args To pass to parse() + * @param string $expectedHTML Expected return value for parse() + * @param string $expectedHTML Expected return value for parseInline(), if different + */ + public function testParse( array $args, $expectedHTML ) { + $op = $this->newInstance(); + $this->assertSame( $expectedHTML, $op->parse( ...$args ) ); + } + + /** + * @dataProvider provideParse + * @covers OutputPage::parseInline + */ + public function testParseInline( array $args, $expectedHTML, $expectedHTMLInline = null ) { + if ( count( $args ) > 3 ) { + // $language param not supported + $this->assertTrue( true ); + return; + } + $op = $this->newInstance(); + $this->assertSame( $expectedHTMLInline ?? $expectedHTML, $op->parseInline( ...$args ) ); + } + + public function provideParse() { + return [ + 'List at start of line' => [ + [ '* List' ], + "
  • List
\n
", + ], + 'List not at start' => [ + [ "* ''Not'' list", false ], + '
* Not list
', + ], + 'Interface' => [ + [ "''Italic''", true, true ], + "

Italic\n

", + 'Italic', + ], + 'formatnum' => [ + [ '{{formatnum:123456.789}}' ], + "

123,456.789\n

", + ], + 'Language' => [ + [ '{{formatnum:123456.789}}', true, false, Language::factory( 'is' ) ], + "

123.456,789\n

", + ], + 'Language with interface' => [ + [ '{{formatnum:123456.789}}', true, true, Language::factory( 'is' ) ], + "

123.456,789\n

", + '123.456,789', + ], + 'No section edit links' => [ + [ '== Header ==' ], + '

' . + "Header

\n
", + ] + ]; + } + + /** + * @covers OutputPage::parse + */ + public function testParseNullTitle() { + $this->setExpectedException( MWException::class, 'Empty $mTitle in OutputPage::parse' ); + $op = $this->newInstance( [], null, 'notitle' ); + $op->parse( '' ); + } + + /** + * @covers OutputPage::parse + */ + public function testParseInlineNullTitle() { + $this->setExpectedException( MWException::class, 'Empty $mTitle in OutputPage::parse' ); + $op = $this->newInstance( [], null, 'notitle' ); + $op->parseInline( '' ); + } + + /** + * @covers OutputPage::setCdnMaxage + * @covers OutputPage::lowerCdnMaxage + */ + public function testCdnMaxage() { + $op = $this->newInstance(); + $wrapper = TestingAccessWrapper::newFromObject( $op ); + $this->assertSame( 0, $wrapper->mCdnMaxage ); + + $op->setCdnMaxage( -1 ); + $this->assertSame( -1, $wrapper->mCdnMaxage ); + + $op->setCdnMaxage( 120 ); + $this->assertSame( 120, $wrapper->mCdnMaxage ); + + $op->setCdnMaxage( 60 ); + $this->assertSame( 60, $wrapper->mCdnMaxage ); + + $op->setCdnMaxage( 180 ); + $this->assertSame( 180, $wrapper->mCdnMaxage ); + + $op->lowerCdnMaxage( 240 ); + $this->assertSame( 180, $wrapper->mCdnMaxage ); + + $op->setCdnMaxage( 300 ); + $this->assertSame( 240, $wrapper->mCdnMaxage ); + + $op->lowerCdnMaxage( 120 ); + $this->assertSame( 120, $wrapper->mCdnMaxage ); + + $op->setCdnMaxage( 180 ); + $this->assertSame( 120, $wrapper->mCdnMaxage ); + + $op->setCdnMaxage( 60 ); + $this->assertSame( 60, $wrapper->mCdnMaxage ); + + $op->setCdnMaxage( 240 ); + $this->assertSame( 120, $wrapper->mCdnMaxage ); + } + + /** @var int Faked time to set for tests that need it */ + private static $fakeTime; + + /** + * @dataProvider provideAdaptCdnTTL + * @covers OutputPage::adaptCdnTTL + * @param array $args To pass to adaptCdnTTL() + * @param int $expected Expected new value of mCdnMaxageLimit + * @param array $options Associative array: + * initialMaxage => Maxage to set before calling adaptCdnTTL() (default 86400) + */ + public function testAdaptCdnTTL( array $args, $expected, array $options = [] ) { + try { + MWTimestamp::setFakeTime( self::$fakeTime ); + + $op = $this->newInstance(); + // Set a high maxage so that it will get reduced by adaptCdnTTL(). The default maxage + // is 0, so adaptCdnTTL() won't mutate the object at all. + $initial = $options['initialMaxage'] ?? 86400; + $op->setCdnMaxage( $initial ); + + $op->adaptCdnTTL( ...$args ); + } finally { + MWTimestamp::setFakeTime( false ); + } + + $wrapper = TestingAccessWrapper::newFromObject( $op ); + + // Special rules for false/null + if ( $args[0] === null || $args[0] === false ) { + $this->assertSame( $initial, $wrapper->mCdnMaxage, 'member value' ); + $op->setCdnMaxage( $expected + 1 ); + $this->assertSame( $expected + 1, $wrapper->mCdnMaxage, 'member value after new set' ); + return; + } + + $this->assertSame( $expected, $wrapper->mCdnMaxageLimit, 'limit value' ); + + if ( $initial >= $expected ) { + $this->assertSame( $expected, $wrapper->mCdnMaxage, 'member value' ); + } else { + $this->assertSame( $initial, $wrapper->mCdnMaxage, 'member value' ); + } + + $op->setCdnMaxage( $expected + 1 ); + $this->assertSame( $expected, $wrapper->mCdnMaxage, 'member value after new set' ); + } + + public function provideAdaptCdnTTL() { + global $wgSquidMaxage; + $now = time(); + self::$fakeTime = $now; + return [ + 'Five minutes ago' => [ [ $now - 300 ], 270 ], + 'Now' => [ [ +0 ], IExpiringStore::TTL_MINUTE ], + 'Five minutes from now' => [ [ $now + 300 ], IExpiringStore::TTL_MINUTE ], + 'Five minutes ago, initial maxage four minutes' => + [ [ $now - 300 ], 270, [ 'initialMaxage' => 240 ] ], + 'A very long time ago' => [ [ $now - 1000000000 ], $wgSquidMaxage ], + 'Initial maxage zero' => [ [ $now - 300 ], 270, [ 'initialMaxage' => 0 ] ], + + 'false' => [ [ false ], IExpiringStore::TTL_MINUTE ], + 'null' => [ [ null ], IExpiringStore::TTL_MINUTE ], + "'0'" => [ [ '0' ], IExpiringStore::TTL_MINUTE ], + 'Empty string' => [ [ '' ], IExpiringStore::TTL_MINUTE ], + // @todo These give incorrect results due to timezones, how to test? + //"'now'" => [ [ 'now' ], IExpiringStore::TTL_MINUTE ], + //"'parse error'" => [ [ 'parse error' ], IExpiringStore::TTL_MINUTE ], + + 'Now, minTTL 0' => [ [ $now, 0 ], IExpiringStore::TTL_MINUTE ], + 'Now, minTTL 0.000001' => [ [ $now, 0.000001 ], 0 ], + 'A very long time ago, maxTTL even longer' => + [ [ $now - 1000000000, 0, 1000000001 ], 900000000 ], + ]; + } + + /** + * @covers OutputPage::enableClientCache + * @covers OutputPage::addParserOutputMetadata + * @covers OutputPage::addParserOutput + */ + public function testClientCache() { + $op = $this->newInstance(); + + // Test initial value + $this->assertSame( true, $op->enableClientCache( null ) ); + // Test that calling with null doesn't change the value + $this->assertSame( true, $op->enableClientCache( null ) ); + + // Test setting to false + $this->assertSame( true, $op->enableClientCache( false ) ); + $this->assertSame( false, $op->enableClientCache( null ) ); + // Test that calling with null doesn't change the value + $this->assertSame( false, $op->enableClientCache( null ) ); + + // Test that a cacheable ParserOutput doesn't set to true + $pOutCacheable = $this->createParserOutputStub( 'isCacheable', true ); + $op->addParserOutputMetadata( $pOutCacheable ); + $this->assertSame( false, $op->enableClientCache( null ) ); + + // Test setting back to true + $this->assertSame( false, $op->enableClientCache( true ) ); + $this->assertSame( true, $op->enableClientCache( null ) ); + + // Test that an uncacheable ParserOutput does set to false + $pOutUncacheable = $this->createParserOutputStub( 'isCacheable', false ); + $op->addParserOutput( $pOutUncacheable ); + $this->assertSame( false, $op->enableClientCache( null ) ); + } + + /** + * @covers OutputPage::getCacheVaryCookies + */ + public function testGetCacheVaryCookies() { + global $wgCookiePrefix, $wgDBname; + $op = $this->newInstance(); + $prefix = $wgCookiePrefix !== false ? $wgCookiePrefix : $wgDBname; + $expectedCookies = [ + "{$prefix}Token", + "{$prefix}LoggedOut", + "{$prefix}_session", + 'forceHTTPS', + 'cookie1', + 'cookie2', + ]; + + // We have to reset the cookies because getCacheVaryCookies may have already been called + TestingAccessWrapper::newFromClass( OutputPage::class )->cacheVaryCookies = null; + + $this->setMwGlobals( 'wgCacheVaryCookies', [ 'cookie1' ] ); + $this->setTemporaryHook( 'GetCacheVaryCookies', + function ( $innerOP, &$cookies ) use ( $op, $expectedCookies ) { + $this->assertSame( $op, $innerOP ); + $cookies[] = 'cookie2'; + $this->assertSame( $expectedCookies, $cookies ); + } + ); + + $this->assertSame( $expectedCookies, $op->getCacheVaryCookies() ); + } + /** * @covers OutputPage::haveCacheVaryCookies */ public function testHaveCacheVaryCookies() { $request = new FauxRequest(); - $context = new RequestContext(); - $context->setRequest( $request ); - $op = new OutputPage( $context ); + $op = $this->newInstance( [], $request ); // No cookies are set. $this->assertFalse( $op->haveCacheVaryCookies() ); @@ -1671,20 +2066,26 @@ class OutputPageTest extends MediaWikiTestCase { * @covers OutputPage::addVaryHeader * @covers OutputPage::getVaryHeader * @covers OutputPage::getKeyHeader + * + * @param array[] $calls For each array, call addVaryHeader() with those arguments + * @param string[] $cookies Array of cookie names to vary on + * @param string $vary Text of expected Vary header (including the 'Vary: ') + * @param string $key Text of expected Key header (including the 'Key: ') */ - public function testVaryHeaders( $calls, $vary, $key ) { - // get rid of default Vary fields + public function testVaryHeaders( array $calls, array $cookies, $vary, $key ) { + // Get rid of default Vary fields $op = $this->getMockBuilder( OutputPage::class ) ->setConstructorArgs( [ new RequestContext() ] ) ->setMethods( [ 'getCacheVaryCookies' ] ) ->getMock(); $op->expects( $this->any() ) ->method( 'getCacheVaryCookies' ) - ->will( $this->returnValue( [] ) ); + ->will( $this->returnValue( $cookies ) ); TestingAccessWrapper::newFromObject( $op )->mVaryHeader = []; + $this->hideDeprecated( '$wgUseKeyHeader' ); foreach ( $calls as $call ) { - call_user_func_array( [ $op, 'addVaryHeader' ], $call ); + $op->addVaryHeader( ...$call ); } $this->assertEquals( $vary, $op->getVaryHeader(), 'Vary:' ); $this->assertEquals( $key, $op->getKeyHeader(), 'Key:' ); @@ -1693,64 +2094,115 @@ class OutputPageTest extends MediaWikiTestCase { public function provideVaryHeaders() { // note: getKeyHeader() automatically adds Vary: Cookie return [ - [ // single header + 'No header' => [ + [], + [], + 'Vary: ', + 'Key: Cookie', + ], + 'Single header' => [ [ [ 'Cookie' ], ], + [], 'Vary: Cookie', 'Key: Cookie', ], - [ // non-unique headers + 'Non-unique headers' => [ [ [ 'Cookie' ], [ 'Accept-Language' ], [ 'Cookie' ], ], + [], 'Vary: Cookie, Accept-Language', 'Key: Cookie,Accept-Language', ], - [ // two headers with single options + 'Two headers with single options' => [ [ [ 'Cookie', [ 'param=phpsessid' ] ], [ 'Accept-Language', [ 'substr=en' ] ], ], + [], 'Vary: Cookie, Accept-Language', 'Key: Cookie;param=phpsessid,Accept-Language;substr=en', ], - [ // one header with multiple options + 'One header with multiple options' => [ [ [ 'Cookie', [ 'param=phpsessid', 'param=userId' ] ], ], + [], 'Vary: Cookie', 'Key: Cookie;param=phpsessid;param=userId', ], - [ // Duplicate option + 'Duplicate option' => [ [ [ 'Cookie', [ 'param=phpsessid' ] ], [ 'Cookie', [ 'param=phpsessid' ] ], [ 'Accept-Language', [ 'substr=en', 'substr=en' ] ], ], + [], 'Vary: Cookie, Accept-Language', 'Key: Cookie;param=phpsessid,Accept-Language;substr=en', ], - [ // Same header, different options + 'Same header, different options' => [ [ [ 'Cookie', [ 'param=phpsessid' ] ], [ 'Cookie', [ 'param=userId' ] ], ], + [], 'Vary: Cookie', 'Key: Cookie;param=phpsessid;param=userId', ], + 'No header, vary cookies' => [ + [], + [ 'cookie1', 'cookie2' ], + 'Vary: Cookie', + 'Key: Cookie;param=cookie1;param=cookie2', + ], + 'Cookie header with option plus vary cookies' => [ + [ + [ 'Cookie', [ 'param=cookie1' ] ], + ], + [ 'cookie2', 'cookie3' ], + 'Vary: Cookie', + 'Key: Cookie;param=cookie1;param=cookie2;param=cookie3', + ], + 'Non-cookie header plus vary cookies' => [ + [ + [ 'Accept-Language' ], + ], + [ 'cookie' ], + 'Vary: Accept-Language, Cookie', + 'Key: Accept-Language,Cookie;param=cookie', + ], + 'Cookie and non-cookie headers plus vary cookies' => [ + [ + [ 'Cookie', [ 'param=cookie1' ] ], + [ 'Accept-Language' ], + ], + [ 'cookie2' ], + 'Vary: Cookie, Accept-Language', + 'Key: Cookie;param=cookie1;param=cookie2,Accept-Language', + ], ]; } + /** + * @covers OutputPage::getVaryHeader + */ + public function testVaryHeaderDefault() { + $op = $this->newInstance(); + $this->assertSame( 'Vary: Accept-Encoding, Cookie', $op->getVaryHeader() ); + } + /** * @dataProvider provideLinkHeaders * * @covers OutputPage::addLinkHeader * @covers OutputPage::getLinkHeader */ - public function testLinkHeaders( $headers, $result ) { + public function testLinkHeaders( array $headers, $result ) { $op = $this->newInstance(); foreach ( $headers as $header ) { @@ -1771,9 +2223,149 @@ class OutputPageTest extends MediaWikiTestCase { 'Link: ;rel=preload;as=image', ], [ - [ ';rel=preload;as=image',';rel=preload;as=image' ], - 'Link: ;rel=preload;as=image,;rel=preload;as=image', + [ + ';rel=preload;as=image', + ';rel=preload;as=image' + ], + 'Link: ;rel=preload;as=image,;' . + 'rel=preload;as=image', + ], + ]; + } + + /** + * @dataProvider provideAddAcceptLanguage + * @covers OutputPage::addAcceptLanguage + * @covers OutputPage::getKeyHeader + */ + public function testAddAcceptLanguage( + $code, array $variants, array $expected, array $options = [] + ) { + $req = new FauxRequest( in_array( 'varianturl', $options ) ? [ 'variant' => 'x' ] : [] ); + $op = $this->newInstance( [], $req, in_array( 'notitle', $options ) ? 'notitle' : null ); + + if ( !in_array( 'notitle', $options ) ) { + $mockLang = $this->getMock( Language::class ); + + if ( in_array( 'varianturl', $options ) ) { + $mockLang->expects( $this->never() )->method( $this->anything() ); + } else { + $mockLang->method( 'hasVariants' )->willReturn( count( $variants ) > 1 ); + $mockLang->method( 'getVariants' )->willReturn( $variants ); + $mockLang->method( 'getCode' )->willReturn( $code ); + } + + $mockTitle = $this->getMock( Title::class ); + $mockTitle->method( 'getPageLanguage' )->willReturn( $mockLang ); + + $op->setTitle( $mockTitle ); + } + + // This will run addAcceptLanguage() + $op->sendCacheControl(); + + $this->hideDeprecated( '$wgUseKeyHeader' ); + $keyHeader = $op->getKeyHeader(); + + if ( !$expected ) { + $this->assertFalse( strpos( 'Accept-Language', $keyHeader ) ); + return; + } + + $keyHeader = explode( ' ', $keyHeader, 2 )[1]; + $keyHeader = explode( ',', $keyHeader ); + + $acceptLanguage = null; + foreach ( $keyHeader as $item ) { + if ( strpos( $item, 'Accept-Language;' ) === 0 ) { + $acceptLanguage = $item; + break; + } + } + + $expectedString = 'Accept-Language;substr=' . implode( ';substr=', $expected ); + $this->assertSame( $expectedString, $acceptLanguage ); + } + + public function provideAddAcceptLanguage() { + return [ + 'No variants' => [ 'en', [ 'en' ], [] ], + 'One simple variant' => [ 'en', [ 'en', 'en-x-piglatin' ], [ 'en-x-piglatin' ] ], + 'Multiple variants with BCP47 alternatives' => [ + 'zh', + [ 'zh', 'zh-hans', 'zh-cn', 'zh-tw' ], + [ 'zh-hans', 'zh-Hans', 'zh-cn', 'zh-Hans-CN', 'zh-tw', 'zh-Hant-TW' ], ], + 'No title' => [ 'en', [ 'en', 'en-x-piglatin' ], [], [ 'notitle' ] ], + 'Variant in URL' => [ 'en', [ 'en', 'en-x-piglatin' ], [], [ 'varianturl' ] ], + ]; + } + + /** + * @covers OutputPage::preventClickjacking + * @covers OutputPage::allowClickjacking + * @covers OutputPage::getPreventClickjacking + * @covers OutputPage::addParserOutputMetadata + * @covers OutputPage::addParserOutput + */ + public function testClickjacking() { + $op = $this->newInstance(); + $this->assertTrue( $op->getPreventClickjacking() ); + + $op->allowClickjacking(); + $this->assertFalse( $op->getPreventClickjacking() ); + + $op->preventClickjacking(); + $this->assertTrue( $op->getPreventClickjacking() ); + + $op->preventClickjacking( false ); + $this->assertFalse( $op->getPreventClickjacking() ); + + $pOut1 = $this->createParserOutputStub( 'preventClickjacking', true ); + $op->addParserOutputMetadata( $pOut1 ); + $this->assertTrue( $op->getPreventClickjacking() ); + + // The ParserOutput can't allow, only prevent + $pOut2 = $this->createParserOutputStub( 'preventClickjacking', false ); + $op->addParserOutputMetadata( $pOut2 ); + $this->assertTrue( $op->getPreventClickjacking() ); + + // Reset to test with addParserOutput() + $op->allowClickjacking(); + $this->assertFalse( $op->getPreventClickjacking() ); + + $op->addParserOutput( $pOut1 ); + $this->assertTrue( $op->getPreventClickjacking() ); + + $op->addParserOutput( $pOut2 ); + $this->assertTrue( $op->getPreventClickjacking() ); + } + + /** + * @dataProvider provideGetFrameOptions + * @covers OutputPage::getFrameOptions + * @covers OutputPage::preventClickjacking + */ + public function testGetFrameOptions( + $breakFrames, $preventClickjacking, $editPageFrameOptions, $expected + ) { + $op = $this->newInstance( [ + 'BreakFrames' => $breakFrames, + 'EditPageFrameOptions' => $editPageFrameOptions, + ] ); + $op->preventClickjacking( $preventClickjacking ); + + $this->assertSame( $expected, $op->getFrameOptions() ); + } + + public function provideGetFrameOptions() { + return [ + 'BreakFrames true' => [ true, false, false, 'DENY' ], + 'Allow clickjacking locally' => [ false, false, 'DENY', false ], + 'Allow clickjacking globally' => [ false, true, false, false ], + 'DENY globally' => [ false, true, 'DENY', 'DENY' ], + 'SAMEORIGIN' => [ false, true, 'SAMEORIGIN', 'SAMEORIGIN' ], + 'BreakFrames with SAMEORIGIN' => [ true, true, 'SAMEORIGIN', 'DENY' ], ]; } @@ -2191,6 +2783,103 @@ class OutputPageTest extends MediaWikiTestCase { ] ); } + /** + * @covers OutputPage::isTOCEnabled + * @covers OutputPage::addParserOutputMetadata + * @covers OutputPage::addParserOutput + */ + public function testIsTOCEnabled() { + $op = $this->newInstance(); + $this->assertFalse( $op->isTOCEnabled() ); + + $pOut1 = $this->createParserOutputStub( 'getTOCHTML', false ); + $op->addParserOutputMetadata( $pOut1 ); + $this->assertFalse( $op->isTOCEnabled() ); + + $pOut2 = $this->createParserOutputStub( 'getTOCHTML', true ); + $op->addParserOutput( $pOut2 ); + $this->assertTrue( $op->isTOCEnabled() ); + + // The parser output doesn't disable the TOC after it was enabled + $op->addParserOutputMetadata( $pOut1 ); + $this->assertTrue( $op->isTOCEnabled() ); + } + + /** + * @dataProvider providePreloadLinkHeaders + * @covers ResourceLoaderSkinModule::getPreloadLinks + * @covers ResourceLoaderSkinModule::getLogoPreloadlinks + */ + public function testPreloadLinkHeaders( $config, $result ) { + $this->setMwGlobals( $config ); + $ctx = $this->getMockBuilder( ResourceLoaderContext::class ) + ->disableOriginalConstructor()->getMock(); + $module = new ResourceLoaderSkinModule(); + + $this->assertEquals( [ $result ], $module->getHeaders( $ctx ) ); + } + + public function providePreloadLinkHeaders() { + return [ + [ + [ + 'wgResourceBasePath' => '/w', + 'wgLogo' => '/img/default.png', + 'wgLogoHD' => [ + '1.5x' => '/img/one-point-five.png', + '2x' => '/img/two-x.png', + ], + ], + 'Link: ;rel=preload;as=image;media=' . + 'not all and (min-resolution: 1.5dppx),' . + ';rel=preload;as=image;media=' . + '(min-resolution: 1.5dppx) and (max-resolution: 1.999999dppx),' . + ';rel=preload;as=image;media=(min-resolution: 2dppx)' + ], + [ + [ + 'wgResourceBasePath' => '/w', + 'wgLogo' => '/img/default.png', + 'wgLogoHD' => false, + ], + 'Link: ;rel=preload;as=image' + ], + [ + [ + 'wgResourceBasePath' => '/w', + 'wgLogo' => '/img/default.png', + 'wgLogoHD' => [ + '2x' => '/img/two-x.png', + ], + ], + 'Link: ;rel=preload;as=image;media=' . + 'not all and (min-resolution: 2dppx),' . + ';rel=preload;as=image;media=(min-resolution: 2dppx)' + ], + [ + [ + 'wgResourceBasePath' => '/w', + 'wgLogo' => '/img/default.png', + 'wgLogoHD' => [ + 'svg' => '/img/vector.svg', + ], + ], + 'Link: ;rel=preload;as=image' + + ], + [ + [ + 'wgResourceBasePath' => '/w', + 'wgLogo' => '/w/test.jpg', + 'wgLogoHD' => false, + 'wgUploadPath' => '/w/images', + 'IP' => dirname( __DIR__ ) . '/data/media', + ], + 'Link: ;rel=preload;as=image', + ], + ]; + } + /** * @return OutputPage */ diff --git a/tests/phpunit/includes/api/ApiQuerySiteinfoTest.php b/tests/phpunit/includes/api/ApiQuerySiteinfoTest.php index 9587a763f1..225c19537b 100644 --- a/tests/phpunit/includes/api/ApiQuerySiteinfoTest.php +++ b/tests/phpunit/includes/api/ApiQuerySiteinfoTest.php @@ -489,6 +489,7 @@ class ApiQuerySiteinfoTest extends ApiTestCase { function ( $code, $name ) { return [ 'code' => $code, + 'bcp47' => LanguageCode::bcp47( $code ), 'name' => $name ]; }, diff --git a/tests/phpunit/includes/cache/MessageCacheTest.php b/tests/phpunit/includes/cache/MessageCacheTest.php index 16448eedc8..661f325149 100644 --- a/tests/phpunit/includes/cache/MessageCacheTest.php +++ b/tests/phpunit/includes/cache/MessageCacheTest.php @@ -129,6 +129,51 @@ class MessageCacheTest extends MediaWikiLangTestCase { $this->assertEquals( $oldText, $messageCache->get( $message ), 'Content restored' ); } + public function testReplaceCache() { + global $wgWANObjectCaches; + + // We need a WAN cache for this. + $this->setMwGlobals( [ + 'wgMainWANCache' => 'hash', + 'wgWANObjectCaches' => $wgWANObjectCaches + [ + 'hash' => [ + 'class' => WANObjectCache::class, + 'cacheId' => 'hash', + 'channels' => [] + ] + ] + ] ); + $this->overrideMwServices(); + + MessageCache::destroyInstance(); + $messageCache = MessageCache::singleton(); + $messageCache->enable(); + + // Populate one key + $this->makePage( 'Key1', 'de', 'Value1' ); + $this->assertEquals( 0, + DeferredUpdates::pendingUpdatesCount(), + 'Post-commit deferred update triggers a run of all updates' ); + $this->assertEquals( 'Value1', $messageCache->get( 'Key1' ), 'Key1 was successfully edited' ); + + // Screw up the database so MessageCache::loadFromDB() will + // produce the wrong result for reloading Key1 + $this->db->delete( + 'page', [ 'page_namespace' => NS_MEDIAWIKI, 'page_title' => 'Key1' ], __METHOD__ + ); + + // Populate the second key + $this->makePage( 'Key2', 'de', 'Value2' ); + $this->assertEquals( 0, + DeferredUpdates::pendingUpdatesCount(), + 'Post-commit deferred update triggers a run of all updates' ); + $this->assertEquals( 'Value2', $messageCache->get( 'Key2' ), 'Key2 was successfully edited' ); + + // Now test that the second edit didn't reload Key1 + $this->assertEquals( 'Value1', $messageCache->get( 'Key1' ), + 'Key1 wasn\'t reloaded by edit of Key2' ); + } + /** * @dataProvider provideNormalizeKey */ diff --git a/tests/phpunit/includes/deferred/CdnCacheUpdateTest.php b/tests/phpunit/includes/deferred/CdnCacheUpdateTest.php index f3c949d3d0..2eaa5e2f49 100644 --- a/tests/phpunit/includes/deferred/CdnCacheUpdateTest.php +++ b/tests/phpunit/includes/deferred/CdnCacheUpdateTest.php @@ -15,17 +15,44 @@ class CdnCacheUpdateTest extends MediaWikiTestCase { $urls1[] = $title->getCanonicalURL( '?x=1' ); $urls1[] = $title->getCanonicalURL( '?x=2' ); $urls1[] = $title->getCanonicalURL( '?x=3' ); - $update1 = new CdnCacheUpdate( $urls1 ); + $update1 = $this->newCdnCacheUpdate( $urls1 ); DeferredUpdates::addUpdate( $update1 ); $urls2 = []; $urls2[] = $title->getCanonicalURL( '?x=2' ); $urls2[] = $title->getCanonicalURL( '?x=3' ); $urls2[] = $title->getCanonicalURL( '?x=4' ); - $update2 = new CdnCacheUpdate( $urls2 ); + $update2 = $this->newCdnCacheUpdate( $urls2 ); DeferredUpdates::addUpdate( $update2 ); $wrapper = TestingAccessWrapper::newFromObject( $update1 ); $this->assertEquals( array_merge( $urls1, $urls2 ), $wrapper->urls ); + + $update = null; + DeferredUpdates::clearPendingUpdates(); + DeferredUpdates::addCallableUpdate( function () use ( $urls1, $urls2, &$update ) { + $update = $this->newCdnCacheUpdate( $urls1 ); + DeferredUpdates::addUpdate( $update ); + DeferredUpdates::addUpdate( $this->newCdnCacheUpdate( $urls2 ) ); + DeferredUpdates::addUpdate( + $this->newCdnCacheUpdate( $urls2 ), DeferredUpdates::PRESEND ); + } ); + DeferredUpdates::doUpdates(); + + $wrapper = TestingAccessWrapper::newFromObject( $update ); + $this->assertEquals( array_merge( $urls1, $urls2 ), $wrapper->urls ); + + $this->assertEquals( DeferredUpdates::pendingUpdatesCount(), 0, 'PRESEND update run' ); + } + + /** + * @param array $urls + * @return CdnCacheUpdate + */ + private function newCdnCacheUpdate( array $urls ) { + return $this->getMockBuilder( CdnCacheUpdate::class ) + ->setConstructorArgs( [ $urls ] ) + ->setMethods( [ 'doUpdate' ] ) + ->getMock(); } } diff --git a/tests/phpunit/languages/LanguageCodeTest.php b/tests/phpunit/languages/LanguageCodeTest.php index 544a063566..d8251bc6f6 100644 --- a/tests/phpunit/languages/LanguageCodeTest.php +++ b/tests/phpunit/languages/LanguageCodeTest.php @@ -54,14 +54,18 @@ class LanguageCodeTest extends PHPUnit\Framework\TestCase { * @dataProvider provideLanguageCodes() */ public function testBcp47( $code, $expected ) { + $this->assertEquals( $expected, LanguageCode::bcp47( $code ), + "Applying BCP 47 standard to '$code'" + ); + $code = strtolower( $code ); $this->assertEquals( $expected, LanguageCode::bcp47( $code ), - "Applying BCP47 standard to lower case '$code'" + "Applying BCP 47 standard to lower case '$code'" ); $code = strtoupper( $code ); $this->assertEquals( $expected, LanguageCode::bcp47( $code ), - "Applying BCP47 standard to upper case '$code'" + "Applying BCP 47 standard to upper case '$code'" ); } @@ -155,6 +159,41 @@ class LanguageCodeTest extends PHPUnit\Framework\TestCase { // de-419-DE // a-DE // ar-a-aaa-b-bbb-a-ccc + + # Non-standard and deprecated language codes used by MediaWiki + [ 'als', 'gsw' ], + [ 'bat-smg', 'sgs' ], + [ 'be-x-old', 'be-tarask' ], + [ 'fiu-vro', 'vro' ], + [ 'roa-rup', 'rup' ], + [ 'zh-classical', 'lzh' ], + [ 'zh-min-nan', 'nan' ], + [ 'zh-yue', 'yue' ], + [ 'cbk-zam', 'cbk' ], + [ 'de-formal', 'de-x-formal' ], + [ 'eml', 'egl' ], + [ 'en-rtl', 'en-x-rtl' ], + [ 'es-formal', 'es-x-formal' ], + [ 'hu-formal', 'hu-x-formal' ], + [ 'kk-Arab', 'kk-Arab' ], + [ 'kk-Cyrl', 'kk-Cyrl' ], + [ 'kk-Latn', 'kk-Latn' ], + [ 'map-bms', 'jv-x-bms' ], + [ 'mo', 'ro-Cyrl-MD' ], + [ 'nrm', 'nrf' ], + [ 'nl-informal', 'nl-x-informal' ], + [ 'roa-tara', 'nap-x-tara' ], + [ 'simple', 'en-simple' ], + [ 'sr-ec', 'sr-Cyrl' ], + [ 'sr-el', 'sr-Latn' ], + [ 'zh-cn', 'zh-Hans-CN' ], + [ 'zh-sg', 'zh-Hans-SG' ], + [ 'zh-my', 'zh-Hans-MY' ], + [ 'zh-tw', 'zh-Hant-TW' ], + [ 'zh-hk', 'zh-Hant-HK' ], + [ 'zh-mo', 'zh-Hant-MO' ], + [ 'zh-hans', 'zh-Hans' ], + [ 'zh-hant', 'zh-Hant' ], ]; } diff --git a/tests/phpunit/languages/LanguageConverterTest.php b/tests/phpunit/languages/LanguageConverterTest.php index 8ccacfc23a..5dcb8e4808 100644 --- a/tests/phpunit/languages/LanguageConverterTest.php +++ b/tests/phpunit/languages/LanguageConverterTest.php @@ -20,7 +20,9 @@ class LanguageConverterTest extends MediaWikiLangTestCase { $this->lang = new LanguageToTest(); $this->lc = new TestConverter( $this->lang, 'tg', - [ 'tg', 'tg-latn' ] + # Adding 'sgs' as a variant to ensure we handle deprecated codes + # adding 'simple' as a variant to ensure we handle non BCP 47 codes + [ 'tg', 'tg-latn', 'sgs', 'simple' ] ); } @@ -38,6 +40,39 @@ class LanguageConverterTest extends MediaWikiLangTestCase { $this->assertEquals( 'tg', $this->lc->getPreferredVariant() ); } + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getURLVariant + */ + public function testGetPreferredVariantUrl() { + global $wgRequest; + $wgRequest->setVal( 'variant', 'tg-latn' ); + + $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getURLVariant + */ + public function testGetPreferredVariantUrlDeprecated() { + global $wgRequest; + $wgRequest->setVal( 'variant', 'bat-smg' ); + + $this->assertEquals( 'sgs', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getURLVariant + */ + public function testGetPreferredVariantUrlBCP47() { + global $wgRequest; + $wgRequest->setVal( 'variant', 'en-simple' ); + + $this->assertEquals( 'simple', $this->lc->getPreferredVariant() ); + } + /** * @covers LanguageConverter::getPreferredVariant * @covers LanguageConverter::getHeaderVariant @@ -49,6 +84,17 @@ class LanguageConverterTest extends MediaWikiLangTestCase { $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); } + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getHeaderVariant + */ + public function testGetPreferredVariantHeadersBCP47() { + global $wgRequest; + $wgRequest->setHeader( 'Accept-Language', 'en-simple' ); + + $this->assertEquals( 'simple', $this->lc->getPreferredVariant() ); + } + /** * @covers LanguageConverter::getPreferredVariant * @covers LanguageConverter::getHeaderVariant @@ -98,6 +144,38 @@ class LanguageConverterTest extends MediaWikiLangTestCase { $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); } + /** + * @covers LanguageConverter::getPreferredVariant + */ + public function testGetPreferredVariantUserOptionDeprecated() { + global $wgUser; + + $wgUser = new User; + $wgUser->load(); // from 'defaults' + $wgUser->mId = 1; + $wgUser->mDataLoaded = true; + $wgUser->mOptionsLoaded = true; + $wgUser->setOption( 'variant', 'bat-smg' ); + + $this->assertEquals( 'sgs', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + */ + public function testGetPreferredVariantUserOptionBCP47() { + global $wgUser; + + $wgUser = new User; + $wgUser->load(); // from 'defaults' + $wgUser->mId = 1; + $wgUser->mDataLoaded = true; + $wgUser->mOptionsLoaded = true; + $wgUser->setOption( 'variant', 'en-simple' ); + + $this->assertEquals( 'simple', $this->lc->getPreferredVariant() ); + } + /** * @covers LanguageConverter::getPreferredVariant * @covers LanguageConverter::getUserVariant @@ -116,6 +194,42 @@ class LanguageConverterTest extends MediaWikiLangTestCase { $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); } + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getUserVariant + */ + public function testGetPreferredVariantUserOptionForForeignLanguageDeprecated() { + global $wgUser; + + $this->setContentLang( 'en' ); + $wgUser = new User; + $wgUser->load(); // from 'defaults' + $wgUser->mId = 1; + $wgUser->mDataLoaded = true; + $wgUser->mOptionsLoaded = true; + $wgUser->setOption( 'variant-tg', 'bat-smg' ); + + $this->assertEquals( 'sgs', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getUserVariant + */ + public function testGetPreferredVariantUserOptionForForeignLanguageBCP47() { + global $wgUser; + + $this->setContentLang( 'en' ); + $wgUser = new User; + $wgUser->load(); // from 'defaults' + $wgUser->mId = 1; + $wgUser->mDataLoaded = true; + $wgUser->mOptionsLoaded = true; + $wgUser->setOption( 'variant-tg', 'en-simple' ); + + $this->assertEquals( 'simple', $this->lc->getPreferredVariant() ); + } + /** * @covers LanguageConverter::getPreferredVariant * @covers LanguageConverter::getUserVariant @@ -145,6 +259,26 @@ class LanguageConverterTest extends MediaWikiLangTestCase { $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); } + /** + * @covers LanguageConverter::getPreferredVariant + */ + public function testGetPreferredVariantDefaultLanguageVariantDeprecated() { + global $wgDefaultLanguageVariant; + + $wgDefaultLanguageVariant = 'bat-smg'; + $this->assertEquals( 'sgs', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + */ + public function testGetPreferredVariantDefaultLanguageVariantBCP47() { + global $wgDefaultLanguageVariant; + + $wgDefaultLanguageVariant = 'en-simple'; + $this->assertEquals( 'simple', $this->lc->getPreferredVariant() ); + } + /** * @covers LanguageConverter::getPreferredVariant * @covers LanguageConverter::getURLVariant @@ -169,9 +303,8 @@ class LanguageConverterTest extends MediaWikiLangTestCase { $testString .= 'xxx xxx xxx'; } $testString .= "\n"; - $old = ini_set( 'pcre.backtrack_limit', 200 ); + $this->setIniSetting( 'pcre.backtrack_limit', 200 ); $result = $this->lc->autoConvert( $testString, 'tg-latn' ); - ini_set( 'pcre.backtrack_limit', $old ); // The в in the id attribute should not get converted to a v $this->assertFalse( strpos( $result, 'v' ), @@ -192,6 +325,8 @@ class TestConverter extends LanguageConverter { function loadDefaultTables() { $this->mTables = [ + 'sgs' => new ReplacementArray(), + 'simple' => new ReplacementArray(), 'tg-latn' => new ReplacementArray( $this->table ), 'tg' => new ReplacementArray() ]; diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js index 3040b85817..2208ab9b1b 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js @@ -692,19 +692,57 @@ // # Tags that use extensions [ 'en-us-u-islamcal', 'en-US-u-islamcal' ], [ 'zh-cn-a-myext-x-private', 'zh-CN-a-myext-x-private' ], - [ 'en-a-myext-b-another', 'en-a-myext-b-another' ] + [ 'en-a-myext-b-another', 'en-a-myext-b-another' ], // # Invalid: // de-419-DE // a-DE // ar-a-aaa-b-bbb-a-ccc + + // Non-standard and deprecated language codes used by MediaWiki + [ 'als', 'gsw' ], + [ 'bat-smg', 'sgs' ], + [ 'be-x-old', 'be-tarask' ], + [ 'fiu-vro', 'vro' ], + [ 'roa-rup', 'rup' ], + [ 'zh-classical', 'lzh' ], + [ 'zh-min-nan', 'nan' ], + [ 'zh-yue', 'yue' ], + [ 'cbk-zam', 'cbk' ], + [ 'de-formal', 'de-x-formal' ], + [ 'eml', 'egl' ], + [ 'en-rtl', 'en-x-rtl' ], + [ 'es-formal', 'es-x-formal' ], + [ 'hu-formal', 'hu-x-formal' ], + [ 'kk-Arab', 'kk-Arab' ], + [ 'kk-Cyrl', 'kk-Cyrl' ], + [ 'kk-Latn', 'kk-Latn' ], + [ 'map-bms', 'jv-x-bms' ], + [ 'mo', 'ro-Cyrl-MD' ], + [ 'nrm', 'nrf' ], + [ 'nl-informal', 'nl-x-informal' ], + [ 'roa-tara', 'nap-x-tara' ], + [ 'simple', 'en-simple' ], + [ 'sr-ec', 'sr-Cyrl' ], + [ 'sr-el', 'sr-Latn' ], + [ 'zh-cn', 'zh-Hans-CN' ], + [ 'zh-sg', 'zh-Hans-SG' ], + [ 'zh-my', 'zh-Hans-MY' ], + [ 'zh-tw', 'zh-Hant-TW' ], + [ 'zh-hk', 'zh-Hant-HK' ], + [ 'zh-mo', 'zh-Hant-MO' ], + [ 'zh-hans', 'zh-Hans' ], + [ 'zh-hant', 'zh-Hant' ] ]; QUnit.test( 'mw.language.bcp47', function ( assert ) { + mw.language.data = this.liveLangData; bcp47Tests.forEach( function ( data ) { var input = data[ 0 ], expected = data[ 1 ]; assert.strictEqual( mw.language.bcp47( input ), expected ); + assert.strictEqual( mw.language.bcp47( input.toLowerCase() ), expected ); + assert.strictEqual( mw.language.bcp47( input.toUpperCase() ), expected ); } ); } ); }() );