README.mediawiki export-ignore
Gemfile* export-ignore
vendor/pear/net_smtp/README.rst export-ignore
-
+phpunit.xml.dist export-ignore
node_modules/
/resources/lib/.foreign
/tests/phpunit/phpunit.phar
+phpunit.xml
/tests/selenium/log
.eslintcache
<!-- Skip violations in some tests for now -->
<exclude-pattern>*/tests/phpunit/includes/GlobalFunctions/*\.php</exclude-pattern>
<exclude-pattern>*/tests/phpunit/maintenance/*\.php</exclude-pattern>
+ <exclude-pattern>*/tests/phpunit/integration/includes/GlobalFunctions/*\.php</exclude-pattern>
</rule>
<rule ref="Generic.Files.OneObjectStructurePerFile.MultipleFound">
engines.
* Skin::escapeSearchLink() is deprecated. Use Skin::getSearchLink() or the skin
template option 'searchaction' instead.
+* LoadBalancer::haveIndex() and LoadBalancer::isNonZeroLoad() have
+ been deprecated.
=== Other changes in 1.34 ===
* …
"test": [
"composer lint",
"composer phpcs"
- ]
+ ],
+ "phpunit": "vendor/bin/phpunit",
+ "phpunit:unit": "vendor/bin/phpunit --colors=always --testsuite=unit",
+ "phpunit:integration": "vendor/bin/phpunit --colors=always --testsuite=integration",
+ "phpunit:coverage": "php -d zend_extensions=xdebug.so vendor/bin/phpunit --testsuite=unit --exclude-group Dump,Broken,ParserFuzz,Stub"
},
"config": {
"optimize-autoloader": true,
/**
* Array mapping class names to filenames, for autoloading.
*/
-$wgAutoloadClasses = [];
+$wgAutoloadClasses = $wgAutoloadClasses ?? [];
/**
* Switch controlling legacy case-insensitive classloading.
use Wikimedia\WrappedStringList;
/**
- * This class should be covered by a general architecture document which does
- * not exist as of January 2011. This is one of the Core classes and should
- * be read at least once by any new developers.
+ * This is one of the Core classes and should
+ * be read at least once by any new developers. Also documented at
+ * https://www.mediawiki.org/wiki/Manual:Architectural_modules/OutputPage
*
* This class is used to prepare the final rendering. A skin is then
* applied to the output parameters (links, javascript, html, categories ...).
// Useful debug output
if ( $wgCommandLineMode ) {
- wfDebug( "\n\nStart command line script $self\n" );
+ if ( isset( $self ) ) {
+ wfDebug( "\n\nStart command line script $self\n" );
+ }
} else {
$debug = "\n\nStart request {$wgRequest->getMethod()} {$wgRequest->getRequestURL()}\n";
);
}
- /**
- * Returns array key value pairs of properties and their descriptions
- *
- * @deprecated since 1.25
- * @param string $modulePrefix
- * @return array
- */
- private static function getProperties( $modulePrefix = '' ) {
- return [
- 'timestamp' => ' timestamp - Adds timestamp for the uploaded version',
- 'user' => ' user - Adds the user who uploaded the image version',
- 'userid' => ' userid - Add the user ID that uploaded the image version',
- 'comment' => ' comment - Comment on the version',
- 'parsedcomment' => ' parsedcomment - Parse the comment on the version',
- 'canonicaltitle' => ' canonicaltitle - Adds the canonical title of the image file',
- 'url' => ' url - Gives URL to the image and the description page',
- 'size' => ' size - Adds the size of the image in bytes, ' .
- 'its height and its width. Page count and duration are added if applicable',
- 'dimensions' => ' dimensions - Alias for size', // B/C with Allimages
- 'sha1' => ' sha1 - Adds SHA-1 hash for the image',
- 'mime' => ' mime - Adds MIME type of the image',
- 'thumbmime' => ' thumbmime - Adds MIME type of the image thumbnail' .
- ' (requires url and param ' . $modulePrefix . 'urlwidth)',
- 'mediatype' => ' mediatype - Adds the media type of the image',
- 'metadata' => ' metadata - Lists Exif metadata for the version of the image',
- 'commonmetadata' => ' commonmetadata - Lists file format generic metadata ' .
- 'for the version of the image',
- 'extmetadata' => ' extmetadata - Lists formatted metadata combined ' .
- 'from multiple sources. Results are HTML formatted.',
- 'archivename' => ' archivename - Adds the file name of the archive ' .
- 'version for non-latest versions',
- 'bitdepth' => ' bitdepth - Adds the bit depth of the version',
- 'uploadwarning' => ' uploadwarning - Used by the Special:Upload page to ' .
- 'get information about an existing file. Not intended for use outside MediaWiki core',
- ];
- }
-
- /**
- * Returns the descriptions for the properties provided by getPropertyNames()
- *
- * @deprecated since 1.25
- * @param array $filter List of properties to filter out
- * @param string $modulePrefix
- * @return array
- */
- public static function getPropertyDescriptions( $filter = [], $modulePrefix = '' ) {
- return array_merge(
- [ 'What image information to get:' ],
- array_values( array_diff_key( static::getProperties( $modulePrefix ), array_flip( $filter ) ) )
- );
- }
-
protected function getExamplesMessages() {
return [
'action=query&titles=File:Albert%20Einstein%20Head.jpg&prop=imageinfo'
"apihelp-query-param-indexpageids": "تضمين قسم إضافي لمعرفات الصفحات يسرد جميع معرفات الصفحات التي تم إرجاعها.",
"apihelp-query-param-export": "تصدير المراجعات الحالية لجميع الصفحات المعينة أو المولدة.",
"apihelp-query-param-exportnowrap": "إعادة تصدير XML دون التفاف عليه في نتيجة XML (نفس شكل [[Special:Export|خاص:تصدير]]). يمكن استخدامها فقط مع $1export.",
+ "apihelp-query-param-exportschema": "استهداف الإصدار المحدد من تنسيق تفريغ XML عند التصدير، يمكن استخدامه مع <var>$1export</var> فقط.",
"apihelp-query-param-iwurl": "ما إذا كنت تريد الحصول على المسار الكامل إذا كان العنوان رابط إنترويكي.",
"apihelp-query-param-rawcontinue": "إرجاع <samp>query-continue</samp> بيانات خام للاستمرار.",
"apihelp-query-example-revisions": "جلب [[Special:ApiHelp/query+siteinfo|معلومات الموقع]] و[[Special:ApiHelp/query+revisions|مراجعات]] <kbd>Main Page</kbd>.",
"apihelp-query+languageinfo-paramvalue-prop-code": "Le code de langue (ce code est spécifique à MédiaWiki, bien qu’il y ait des recouvrements avec d’autres standards).",
"apihelp-query+languageinfo-paramvalue-prop-bcp47": "Le code de langue BCP-47.",
"apihelp-query+languageinfo-paramvalue-prop-dir": "La direction d’écriture de la langue (<code>ltr</code> ou <code>rtl</code>).",
- "apihelp-query+languageinfo-paramvalue-prop-autonym": "L’autonyme d&une langue, c’est-à-dire son nom dans cette langue.",
+ "apihelp-query+languageinfo-paramvalue-prop-autonym": "L’autonyme d'une langue, c’est-à-dire son nom dans cette langue.",
"apihelp-query+languageinfo-paramvalue-prop-name": "Le nom de la langue dans la langue spécifiée par le paramètre <var>lilang</var>, avec application des langues de secours si besoin.",
"apihelp-query+languageinfo-paramvalue-prop-fallbacks": "Les codes de langue des langues de secours configurées pour cette langue. Le secours implicite final en 'en' n’est pas inclus (mais certaines langues peuvent avoir 'en' en secours explicitement).",
"apihelp-query+languageinfo-paramvalue-prop-variants": "Les codes de langue des variantes supportées par cette langue.",
"apihelp-main-param-servedby": "Вклучи го домаќинското име што го услужило барањето во исходот.",
"apihelp-main-param-curtimestamp": "Вклучи тековно време и време и датум во исходот.",
"apihelp-main-param-origin": "Кога му пристапувате на Пирлогот користејќи повеќедоменско AJAX-барање (CORS), задајте му го на ова изворниот домен. Ова мора да се вклучи во секое подготвително барање и затоа мора да биде дел од URI на барањето (не главната содржина во POST). Ова мора точно да се совпаѓа со еден од изворниците на заглавието Origin:, така што мора да е зададен на нешто како <kbd>https://mk.wikipedia.org</kbd> or <kbd>https://meta.wikimedia.org</kbd>. Ако овој параметар не се совпаѓа со заглавието <code>Origin</code>:, ќе се појави одговор 403. Ако се совпаѓа, а изворникот е на бел список (на допуштени), тогаш ќе се зададе заглавието <code>Access-Control-Allow-Origin</code>.",
- "apihelp-main-param-uselang": "Јазик за преведување на пораките. Список на јазични кодови ќе најдете на <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> со <kbd>siprop=languages</kbd> или укажете <kbd>user</kbd> за да го користите тековно зададениот јазик корисникот, или пак укажете <kbd>content</kbd> за да го користите јазикот на содржината на ова вики.",
+ "apihelp-main-param-uselang": "Јазик за преведување на пораките. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> со <kbd>siprop=languages</kbd> дава список на јазични кодови, или укажете <kbd>user</kbd> за да го користите тековно зададениот јазик корисникот, или пак укажете <kbd>content</kbd> за да го користите јазикот на содржината на ова вики.",
"apihelp-block-summary": "Блокирај корисник.",
- "apihelp-block-param-user": "Корисничко име, IP-адреса или IP-опсег ако сакате да блокирате.",
+ "apihelp-block-param-user": "Корисничко име, IP-адреса или IP-опсег ако сакате да блокирате. Не може да се користи заедно со <var>$1userid</var>",
"apihelp-block-param-expiry": "Време на истек. Може да биде релативно (на пр. <kbd>5 months</kbd> или „2 недели“) или пак апсолутно (на пр. <kbd>2014-09-18T12:34:56Z</kbd>). Ако го зададете <kbd>infinite</kbd>, <kbd>indefinite</kbd> или <kbd>never</kbd>, блокот ќе трае засекогаш.",
"apihelp-block-param-reason": "Причина за блокирање.",
"apihelp-block-param-anononly": "Блокирај само анонимни корисници (т.е. оневозможи анонимно уредување од оваа IP-адреса).",
"apihelp-block-param-allowusertalk": "Овозможи му на корисникот да ја уредува неговата разговорна страница (зависи од <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
"apihelp-block-param-reblock": "Ако корисникот е веќе блокиран, наметни врз постоечкиот блок.",
"apihelp-block-param-watchuser": "Набљудувај ја корисничката страница и разговорна страница на овој корисник или IP-адреса",
+ "apihelp-block-param-tags": "Ознаки за примена врз ставката во дневникот на блокирања.",
"apihelp-block-example-ip-simple": "Блокирај ја IP-адресата <kbd>192.0.2.5</kbd> три дена со причината <kbd>Прва опомена</kbd>.",
"apihelp-block-example-user-complex": "Блокирај го корисникот <kbd>Vandal</kbd> (Вандал) бесконечно со причината <kbd>Vandal</kbd> (Вандализам) и оневозможи создавање на нови сметки и праќање е-пошта.",
"apihelp-checktoken-summary": "Проверка на полноважноста на шифрата од <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
"apihelp-login-example-login": "Најава",
"apihelp-logout-summary": "Одјави се и исчисти ги податоците на седницата.",
"apihelp-logout-example-logout": "Одјави го тековниот корисник",
+ "apihelp-mergehistory-summary": "Спојување на истории на страници.",
"apihelp-move-summary": "Премести страница.",
"apihelp-move-param-from": "Наслов на страницата што треба да се премести. Не може да се користи заедно со <var>$1fromid</var>.",
"apihelp-move-param-fromid": "Назнака на страницата што треба да се премести. Не може да се користи заедно со <var>$1from</var>.",
"apihelp-query+allcategories-param-from": "Од која категорија да почне набројувањето.",
"apihelp-query+allcategories-param-to": "На која категорија да запре набројувањето.",
"apihelp-query+allcategories-param-dir": "Насока на подредувањето.",
+ "apihelp-query+allcategories-param-prop": "Кои својства да се дадат:",
"apihelp-query+alldeletedrevisions-param-from": "Почни го исписот од овој наслов.",
"apihelp-query+alldeletedrevisions-param-to": "Запри го исписот на овој наслов.",
"apihelp-query+alldeletedrevisions-example-user": "Список на последните 50 избришани придонеси на корисникот <kbd>Example</kbd>.",
"apihelp-query+alldeletedrevisions-example-ns-main": "Список на последните 50 избришани преработки во главниот именски простор.",
+ "apihelp-query+allfileusages-param-prop": "Кои информации да се вклучат:",
+ "apihelp-query+allfileusages-paramvalue-prop-title": "Го додава насловот на податотеката.",
+ "apihelp-query+allfileusages-param-limit": "Колку вкупно ставки да се дадат.",
+ "apihelp-query+allfileusages-param-dir": "Насока на исписот.",
+ "apihelp-query+allfileusages-example-unique": "Испиши единствени наслови на податотеки.",
+ "apihelp-query+allfileusages-example-unique-generator": "Ги дава сите наслови на податотеки, означувајќи ги отсутните.",
+ "apihelp-query+allfileusages-example-generator": "Дава страници што ги содржат податотеките.",
+ "apihelp-query+allimages-param-dir": "Насока на исписот.",
"apihelp-query+allimages-example-b": "Прикажи список на податотеки што почнуваат со буквата <kbd>B</kbd>.",
"apihelp-query+allimages-example-recent": "Прикажи список на неодамна подигнати податотеки сличен на [[Special:NewFiles]]",
"apihelp-query+allimages-example-generator": "Прикажи информации за околу 4 податотеки што почнуваат со буквата <kbd>T</kbd>.",
"apihelp-query+allpages-param-minsize": "Ограничи на страници со барем олку бајти.",
"apihelp-query+allpages-param-maxsize": "Ограничи на страници со највеќе олку бајти.",
"apihelp-query+allpages-param-prtype": "Ограничи на само заштитени страници.",
+ "apihelp-query+allpages-param-limit": "Колку вкупно страници да се дадат.",
+ "apihelp-query+allpages-param-dir": "Насока на исписот.",
+ "apihelp-query+allredirects-param-prop": "Кои информации да се вклучат:",
+ "apihelp-query+allredirects-paramvalue-prop-title": "Го додава насловот на пренасочувањето.",
+ "apihelp-query+allredirects-param-namespace": "Именскиот простор што се набројува.",
+ "apihelp-query+allredirects-param-limit": "Колку вкупно ставки да се дадат.",
+ "apihelp-query+allredirects-param-dir": "Насока на исписот.",
+ "apihelp-query+allrevisions-param-start": "Од кој датум и време да почне набројувањето.",
+ "apihelp-query+allrevisions-param-end": "На кој датум и време да запре набројувањето.",
+ "apihelp-query+alltransclusions-param-prop": "Кои информации да се вклучат:",
+ "apihelp-query+alltransclusions-param-namespace": "Именскиот простор што се набројува.",
+ "apihelp-query+alltransclusions-param-limit": "Колку вкупно ставки да се дадат.",
+ "apihelp-query+alltransclusions-param-dir": "Насока на исписот.",
+ "apihelp-query+allusers-param-from": "Од кое корисничко име да почне набројувањето.",
+ "apihelp-query+allusers-param-to": "На кое корисничко име да престане набројувањето.",
+ "apihelp-query+allusers-param-prefix": "Пребарај ги сите корисници што почнуваат со оваа вредност.",
+ "apihelp-query+allusers-param-dir": "Насока на подредувањето.",
+ "apihelp-query+allusers-param-group": "Вклучи ги корисниците само од дадените групи.",
+ "apihelp-query+allusers-param-excludegroup": "Исклучи ги корисниците од дадените групи.",
+ "apihelp-query+allusers-param-prop": "Кои информации да се вклучат:",
+ "apihelp-query+allusers-paramvalue-prop-blockinfo": "Ги додава информациите за тековното блокирање на корисникот.",
+ "apihelp-query+allusers-param-limit": "Колку вкупно кориснички имиња да се дадат.",
+ "apihelp-query+backlinks-param-namespace": "Именскиот простор што се набројува.",
+ "apihelp-query+backlinks-param-dir": "Насока на исписот.",
"apihelp-query+backlinks-example-simple": "Прикажи врски до <kbd>Main page</kbd>.",
"apihelp-query+backlinks-example-generator": "Дава информации за страниците што водат до <kbd>Main page</kbd>.",
"apihelp-query+blocks-summary": "Список на сите блокирани корисници и IP-адреси",
"apihelp-query+blocks-param-end": "На кој датум и време да запре набројувањето.",
"apihelp-query+blocks-param-ids": "Список на назнаки на блоковите за испис (незадолжително)",
"apihelp-query+blocks-param-users": "Список на корисници што ќе се пребаруваат (незадолжително)",
+ "apihelp-query+blocks-param-prop": "Кои својства да се дадат:",
+ "apihelp-query+categories-param-dir": "Насока на исписот.",
+ "apihelp-query+categorymembers-param-prop": "Кои информации да се вклучат:",
+ "apihelp-query+categorymembers-param-limit": "Највеќе страници за прикажување.",
"apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|Режим|Режими}}: $2",
+ "apihelp-query+deletedrevs-param-start": "Од кој датум и време да почне набројувањето.",
+ "apihelp-query+deletedrevs-param-end": "На кој датум и време да запре набројувањето.",
+ "apihelp-query+deletedrevs-param-from": "Почни го исписот од овој наслов.",
+ "apihelp-query+deletedrevs-param-to": "Запри го исписот на овој наслов.",
+ "apihelp-query+deletedrevs-param-prefix": "Пребарај ги сите наслови на страници што почнуваат со оваа вредност.",
+ "apihelp-query+disabled-summary": "Овој модул за барања е оневозможен.",
+ "apihelp-query+duplicatefiles-param-dir": "Насока на исписот.",
+ "apihelp-query+embeddedin-param-namespace": "Именскиот простор што се набројува.",
+ "apihelp-query+embeddedin-param-dir": "Насока на исписот.",
+ "apihelp-query+embeddedin-param-filterredir": "Како да се филтрираат пренасочувањата.",
+ "apihelp-query+embeddedin-param-limit": "Колку вкупно страници да се дадат.",
+ "apihelp-query+extlinks-param-limit": "Колку врски да се дадат.",
+ "apihelp-query+exturlusage-param-prop": "Кои информации да се вклучат:",
+ "apihelp-query+exturlusage-param-limit": "Колку страници да се дадат.",
+ "apihelp-query+filearchive-param-from": "Наслов на сликата од која ќе почне набројувањето.",
+ "apihelp-query+filearchive-param-to": "Наслов на сликата на која ќе запре набројувањето.",
+ "apihelp-query+filearchive-param-prefix": "Пребарај ги сите наслови на слики што почнуваат со оваа вредност.",
+ "apihelp-query+filearchive-param-limit": "Колку вкупно слики да се дадат.",
+ "apihelp-query+filearchive-param-dir": "Насока на исписот.",
+ "apihelp-query+filearchive-param-prop": "Кои информации за слики да се дадат:",
+ "apihelp-query+filearchive-example-simple": "Прикажи список на сите избришани податотеки.",
+ "apihelp-query+fileusage-param-prop": "Кои својства да се дадат:",
+ "apihelp-query+imageinfo-param-prop": "Кои информации за податотеки да се дадат:",
"apihelp-query+imageinfo-param-urlheight": "Слично на $1urlwidth.",
+ "apihelp-query+images-param-limit": "Колку податотеки да се дадат.",
+ "apihelp-query+images-param-dir": "Насока на исписот.",
+ "apihelp-query+imageusage-param-namespace": "Именскиот простор што се набројува.",
+ "apihelp-query+imageusage-param-dir": "Насока на исписот.",
+ "apihelp-query+iwbacklinks-param-limit": "Колку вкупно страници да се дадат.",
+ "apihelp-query+iwbacklinks-param-prop": "Кои својства да се дадат:",
+ "apihelp-query+iwbacklinks-param-dir": "Насока на исписот.",
+ "apihelp-query+iwlinks-param-dir": "Насока на исписот.",
+ "apihelp-query+langbacklinks-param-limit": "Колку вкупно страници да се дадат.",
+ "apihelp-query+langbacklinks-param-prop": "Кои својства да се дадат:",
+ "apihelp-query+langbacklinks-param-dir": "Насока на исписот.",
+ "apihelp-query+langlinks-param-dir": "Насока на исписот.",
+ "apihelp-query+links-param-limit": "Колку врски да се дадат.",
+ "apihelp-query+links-param-dir": "Насока на исписот.",
+ "apihelp-query+linkshere-param-prop": "Кои својства да се дадат:",
+ "apihelp-query+logevents-param-prop": "Кои својства да се дадат:",
+ "apihelp-query+logevents-param-start": "Од кој датум и време да почне набројувањето.",
+ "apihelp-query+logevents-param-end": "На кој датум и време да запре набројувањето.",
+ "apihelp-query+pageswithprop-param-prop": "Кои информации да се вклучат:",
+ "apihelp-query+pageswithprop-param-limit": "Највеќе страници за прикажување.",
+ "apihelp-query+prefixsearch-param-search": "Низа за пребарување.",
+ "apihelp-query+prefixsearch-param-limit": "Највеќе ставки во исходот за прикажување.",
+ "apihelp-query+protectedtitles-param-limit": "Колку вкупно страници да се дадат.",
+ "apihelp-query+protectedtitles-param-prop": "Кои својства да се дадат:",
+ "apihelp-query+random-param-filterredir": "Како да се филтрираат пренасочувањата.",
+ "apihelp-query+recentchanges-param-start": "Од кој датум и време да почне набројувањето.",
+ "apihelp-query+recentchanges-param-end": "На кој датум и време да запре набројувањето.",
+ "apihelp-query+recentchanges-param-limit": "Колку вкупно промени да се дадат.",
+ "apihelp-query+redirects-param-prop": "Кои својства да се дадат:",
+ "apihelp-query+redirects-param-limit": "Колку пренасочувања да се дадат.",
+ "apihelp-query+redirects-example-simple": "Дај список на пренасочувања до [[Main Page|Главната страница]].",
"apihelp-query+revisions-example-last5": "Дај ги последните 5 преработки на <kbd>Главна страница</kbd>.",
"apihelp-query+revisions-example-first5": "Дај ги првите 5 преработки на <kbd>Главна страница</kbd>.",
"apihelp-query+revisions-example-first5-after": "Дај ги првите 5 преработки на <kbd>Главна страница</kbd> направени по 2006-05-01 (1 мај 2006 г.)",
"apihelp-query+revisions-example-first5-not-localhost": "Дај ги првите 5 преработки на <kbd>Главна страница</kbd> кои не се направени од анонимниот корисник „127.0.0.1“",
"apihelp-query+revisions-example-first5-user": "Дај ги првите 5 преработки на <kbd>Главна страница</kbd> кои се направени од корисникот „зададен од МедијаВики“ (<kbd>MediaWiki default</kbd>)",
+ "apihelp-query+search-param-namespace": "Пребарување само во овие именски простори.",
+ "apihelp-query+search-param-info": "Кои метаподатоци да се дадат.",
+ "apihelp-query+search-param-prop": "Кои својства да се дадат:",
+ "apihelp-query+search-paramvalue-prop-score": "Занемарено.",
+ "apihelp-query+search-paramvalue-prop-hasrelated": "Занемарено.",
+ "apihelp-query+search-param-limit": "Колку вкупно страници да се дадат.",
"apihelp-query+search-example-simple": "Побарај <kbd>meaning</kbd>.",
"apihelp-query+search-example-text": "Побарај го <kbd>meaning</kbd> по текстовите.",
"apihelp-query+search-example-generator": "Дај информации за страниците што излегуваат во исходот од пребарувањето на <kbd>meaning</kbd>.",
"apihelp-query+siteinfo-summary": "Дај општи информации за мрежното место.",
+ "apihelp-query+siteinfo-param-prop": "Кои информации да се дадат:",
+ "apihelp-query+tags-param-limit": "Најголемиот број на ознаки за наведување во списокот.",
+ "apihelp-query+tags-param-prop": "Кои својства да се дадат:",
+ "apihelp-query+templates-param-limit": "Колку шаблони да се дадат.",
+ "apihelp-query+templates-param-dir": "Насока на исписот.",
+ "apihelp-query+transcludedin-param-prop": "Кои својства да се дадат:",
+ "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Ги означува проверените уредувања.",
+ "apihelp-query+usercontribs-paramvalue-prop-autopatrolled": "Ги означува самопроверените уредувања.",
+ "apihelp-query+userinfo-param-prop": "Кои информации да се вклучат:",
+ "apihelp-query+users-param-prop": "Кои информации да се вклучат:",
+ "apihelp-query+watchlist-param-start": "Од кој датум и време да почне набројувањето.",
+ "apihelp-query+watchlist-param-end": "На кој датум и време да запре набројувањето.",
+ "apihelp-query+watchlist-paramvalue-type-new": "Создавања на страници.",
+ "apihelp-query+watchlist-paramvalue-type-log": "Дневнички записи.",
+ "apihelp-query+watchlistraw-param-dir": "Насока на исписот.",
+ "apihelp-revisiondelete-param-suppress": "Дали се притајуваат податоци од администраторите на ист начин како и за останатите.",
+ "apihelp-revisiondelete-param-tags": "Ознаки за примена врз ставката во дневникот на бришења.",
"apihelp-upload-param-filename": "Целно име на податотеката.",
"apihelp-upload-param-comment": "Коментар при подигање. Се користи и како првичен текст на страницата за нови податотеки ако не е укажано <var>$1text</var>.",
"apihelp-upload-param-text": "Првичен текст на страницата за нови податотеки.",
}
function execFlags() {
- return $this->trxLevel ? OCI_NO_AUTO_COMMIT : OCI_COMMIT_ON_SUCCESS;
+ return $this->trxLevel() ? OCI_NO_AUTO_COMMIT : OCI_COMMIT_ON_SUCCESS;
}
/**
}
}
- if ( !$this->trxLevel ) {
+ if ( !$this->trxLevel() ) {
oci_commit( $this->conn );
}
}
protected function doBegin( $fname = __METHOD__ ) {
- $this->trxLevel = 1;
- $this->doQuery( 'SET CONSTRAINTS ALL DEFERRED' );
+ $this->query( 'SET CONSTRAINTS ALL DEFERRED' );
}
protected function doCommit( $fname = __METHOD__ ) {
- if ( $this->trxLevel ) {
+ if ( $this->trxLevel() ) {
$ret = oci_commit( $this->conn );
if ( !$ret ) {
throw new DBUnexpectedError( $this, $this->lastError() );
}
- $this->trxLevel = 0;
- $this->doQuery( 'SET CONSTRAINTS ALL IMMEDIATE' );
+ $this->query( 'SET CONSTRAINTS ALL IMMEDIATE' );
}
}
protected function doRollback( $fname = __METHOD__ ) {
- if ( $this->trxLevel ) {
+ if ( $this->trxLevel() ) {
oci_rollback( $this->conn );
- $this->trxLevel = 0;
- $this->doQuery( 'SET CONSTRAINTS ALL IMMEDIATE' );
+ $ignoreErrors = true;
+ $this->query( 'SET CONSTRAINTS ALL IMMEDIATE', $fname, $ignoreErrors );
}
}
}
}
- if ( !$this->trxLevel ) {
+ if ( !$this->trxLevel() ) {
oci_commit( $this->conn );
}
"config-env-php": "PHP $1 đã được cài đặt.",
"config-env-hhvm": "HHVM $1 được cài đặt.",
"config-unicode-using-intl": "Sẽ sử dụng [https://pecl.php.net/intl phần mở rộng PECL intl] để chuẩn hóa Unicode.",
- "config-unicode-pure-php-warning": "<strong>Cảnh báo:</strong> [https://pecl.php.net/intl intl PECL extension] không được phép xử lý Unicode chuẩn hóa, trả lại thực thi PHP-gốc chậm.\nNếu bạn chạy một site lưu lượng lớn, bạn phải để ý qua một chút trên [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalization].",
+ "config-unicode-pure-php-warning": "<strong>Cảnh báo:</strong> [https://pecl.php.net/intl PECL intl extension] không được phép xử lý Unicode chuẩn hóa, trả lại thực thi PHP-gốc chậm.\nNếu bạn chạy một site lưu lượng lớn, bạn nên đọc qua [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations chuẩn hóa Unicode].",
"config-unicode-update-warning": "<strong>Cảnh báo:</strong> Phiên bản cài đặt của gói Unicode chuẩn hóa sử dụng một phiên bản cũ của thư viện [http://site.icu-project.org/ the ICU project].\nBạn phải [https://www.mediawiki.org/wiki/Special:MyLanguage/nâng cấp Unicode_normalization_considerations] nếu bạn quan tâm đến việc sử dụng Unicode.",
"config-no-db": "Không tìm thấy một trình điều khiển cơ sở dữ liệu phù hợp! Bạn cần phải cài một trình điều khiển cơ sở dữ liệu cho PHP.\n{{PLURAL:$2|Loại|Các loại}} cơ sở dữ liệu sau đây được hỗ trợ: $1.\n\nNếu bạn đã biên dịch PHP lấy, cấu hình lại nó mà kích hoạt một trình khách cơ sở dữ liệu, ví dụ bằng lệnh <code>./configure --with-mysqli</code>.\nNếu bạn đã cài PHP từ một gói Debian hoặc Ubuntu, thì bạn cũng cần phải cài ví dụ gói <code>php-mysql</code>.",
"config-outdated-sqlite": "<strong>Chú ý:</strong> Bạn có SQLite $1, phiên bản này thấp hơn phiên bản yêu câu tối thiểu $2. SQLite sẽ không có tác dụng.",
/** @var array Map of (table name => 1) for TEMPORARY tables */
protected $sessionTempTables = [];
- /** @var int Whether there is an active transaction (1 or 0) */
- protected $trxLevel = 0;
- /** @var string Hexidecimal string if a transaction is active or empty string otherwise */
+ /** @var string ID of the active transaction or the empty string otherwise */
protected $trxShortId = '';
/** @var int Transaction status */
protected $trxStatus = self::STATUS_TRX_NONE;
return $res;
}
- public function trxLevel() {
- return $this->trxLevel;
+ final public function trxLevel() {
+ return ( $this->trxShortId != '' ) ? 1 : 0;
}
public function trxTimestamp() {
- return $this->trxLevel ? $this->trxTimestamp : null;
+ return $this->trxLevel() ? $this->trxTimestamp : null;
}
/**
}
public function writesPending() {
- return $this->trxLevel && $this->trxDoneWrites;
+ return $this->trxLevel() && $this->trxDoneWrites;
}
public function writesOrCallbacksPending() {
- return $this->trxLevel && (
+ return $this->trxLevel() && (
$this->trxDoneWrites ||
$this->trxIdleCallbacks ||
$this->trxPreCommitCallbacks ||
}
public function preCommitCallbacksPending() {
- return $this->trxLevel && $this->trxPreCommitCallbacks;
+ return $this->trxLevel() && $this->trxPreCommitCallbacks;
}
/**
}
public function pendingWriteQueryDuration( $type = self::ESTIMATE_TOTAL ) {
- if ( !$this->trxLevel ) {
+ if ( !$this->trxLevel() ) {
return false;
} elseif ( !$this->trxDoneWrites ) {
return 0.0;
}
public function pendingWriteCallers() {
- return $this->trxLevel ? $this->trxWriteCallers : [];
+ return $this->trxLevel() ? $this->trxWriteCallers : [];
}
public function pendingWriteRowsAffected() {
// This should mostly do nothing if the connection is already closed
if ( $this->conn ) {
// Roll back any dangling transaction first
- if ( $this->trxLevel ) {
+ if ( $this->trxLevel() ) {
if ( $this->trxAtomicLevels ) {
// Cannot let incomplete atomic sections be committed
$levels = $this->flatAtomicSectionList();
final protected function executeQuery( $sql, $fname, $flags ) {
$this->assertHasConnectionHandle();
- $priorTransaction = $this->trxLevel;
+ $priorTransaction = $this->trxLevel();
if ( $this->isWriteQuery( $sql ) ) {
# In theory, non-persistent writes are allowed in read-only mode, but due to things
// Keep track of whether the transaction has write queries pending
if ( $isPermWrite ) {
$this->lastWriteTime = microtime( true );
- if ( $this->trxLevel && !$this->trxDoneWrites ) {
+ if ( $this->trxLevel() && !$this->trxDoneWrites ) {
$this->trxDoneWrites = true;
$this->trxProfiler->transactionWritingIn(
$this->server, $this->getDomainID(), $this->trxShortId );
if ( $ret !== false ) {
$this->lastPing = $startTime;
- if ( $isPermWrite && $this->trxLevel ) {
+ if ( $isPermWrite && $this->trxLevel() ) {
$this->updateTrxWriteQueryTime( $sql, $queryRuntime, $this->affectedRows() );
$this->trxWriteCallers[] = $fname;
}
*/
private function beginIfImplied( $sql, $fname ) {
if (
- !$this->trxLevel &&
+ !$this->trxLevel() &&
$this->getFlag( self::DBO_TRX ) &&
$this->isTransactableQuery( $sql )
) {
// https://www.postgresql.org/docs/9.4/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
$this->sessionNamedLocks = [];
// Session loss implies transaction loss
- $this->trxLevel = 0;
+ $oldTrxShortId = $this->consumeTrxShortId();
$this->trxAtomicCounter = 0;
$this->trxIdleCallbacks = []; // T67263; transaction already lost
$this->trxPreCommitCallbacks = []; // T67263; transaction already lost
$this->trxProfiler->transactionWritingOut(
$this->server,
$this->getDomainID(),
- $this->trxShortId,
+ $oldTrxShortId,
$this->pendingWriteQueryDuration( self::ESTIMATE_TOTAL ),
$this->trxWriteAffectedRows
);
}
}
+ /**
+ * Reset the transaction ID and return the old one
+ *
+ * @return string The old transaction ID or the empty string if there wasn't one
+ */
+ private function consumeTrxShortId() {
+ $old = $this->trxShortId;
+ $this->trxShortId = '';
+
+ return $old;
+ }
+
/**
* Checks whether the cause of the error is detected to be a timeout.
*
public function lockForUpdate(
$table, $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
) {
- if ( !$this->trxLevel && !$this->getFlag( self::DBO_TRX ) ) {
+ if ( !$this->trxLevel() && !$this->getFlag( self::DBO_TRX ) ) {
throw new DBUnexpectedError(
$this,
__METHOD__ . ': no transaction is active nor is DBO_TRX set'
}
final public function onTransactionResolution( callable $callback, $fname = __METHOD__ ) {
- if ( !$this->trxLevel ) {
+ if ( !$this->trxLevel() ) {
throw new DBUnexpectedError( $this, "No transaction is active." );
}
$this->trxEndCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ];
}
final public function onTransactionCommitOrIdle( callable $callback, $fname = __METHOD__ ) {
- if ( !$this->trxLevel && $this->getTransactionRoundId() ) {
+ if ( !$this->trxLevel() && $this->getTransactionRoundId() ) {
// Start an implicit transaction similar to how query() does
$this->begin( __METHOD__, self::TRANSACTION_INTERNAL );
$this->trxAutomatic = true;
}
$this->trxIdleCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ];
- if ( !$this->trxLevel ) {
+ if ( !$this->trxLevel() ) {
$this->runOnTransactionIdleCallbacks( self::TRIGGER_IDLE );
}
}
}
final public function onTransactionPreCommitOrIdle( callable $callback, $fname = __METHOD__ ) {
- if ( !$this->trxLevel && $this->getTransactionRoundId() ) {
+ if ( !$this->trxLevel() && $this->getTransactionRoundId() ) {
// Start an implicit transaction similar to how query() does
$this->begin( __METHOD__, self::TRANSACTION_INTERNAL );
$this->trxAutomatic = true;
}
- if ( $this->trxLevel ) {
+ if ( $this->trxLevel() ) {
$this->trxPreCommitCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ];
} else {
// No transaction is active nor will start implicitly, so make one for this callback
}
final public function onAtomicSectionCancel( callable $callback, $fname = __METHOD__ ) {
- if ( !$this->trxLevel || !$this->trxAtomicLevels ) {
+ if ( !$this->trxLevel() || !$this->trxAtomicLevels ) {
throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." );
}
$this->trxSectionCancelCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ];
* @return AtomicSectionIdentifier|null ID of the topmost atomic section level
*/
private function currentAtomicSectionId() {
- if ( $this->trxLevel && $this->trxAtomicLevels ) {
+ if ( $this->trxLevel() && $this->trxAtomicLevels ) {
$levelInfo = end( $this->trxAtomicLevels );
return $levelInfo[1];
* @throws Exception
*/
public function runOnTransactionIdleCallbacks( $trigger ) {
- if ( $this->trxLevel ) { // sanity
+ if ( $this->trxLevel() ) { // sanity
throw new DBUnexpectedError( $this, __METHOD__ . ': a transaction is still open.' );
}
) {
$savepointId = $cancelable === self::ATOMIC_CANCELABLE ? self::$NOT_APPLICABLE : null;
- if ( !$this->trxLevel ) {
+ if ( !$this->trxLevel() ) {
$this->begin( $fname, self::TRANSACTION_INTERNAL ); // sets trxAutomatic
// If DBO_TRX is set, a series of startAtomic/endAtomic pairs will result
// in all changes being in one transaction to keep requests transactional.
}
final public function endAtomic( $fname = __METHOD__ ) {
- if ( !$this->trxLevel || !$this->trxAtomicLevels ) {
+ if ( !$this->trxLevel() || !$this->trxAtomicLevels ) {
throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." );
}
final public function cancelAtomic(
$fname = __METHOD__, AtomicSectionIdentifier $sectionId = null
) {
- if ( !$this->trxLevel || !$this->trxAtomicLevels ) {
+ if ( !$this->trxLevel() || !$this->trxAtomicLevels ) {
throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." );
}
}
// Protect against mismatched atomic section, transaction nesting, and snapshot loss
- if ( $this->trxLevel ) {
+ if ( $this->trxLevel() ) {
if ( $this->trxAtomicLevels ) {
$levels = $this->flatAtomicSectionList();
$msg = "$fname: Got explicit BEGIN while atomic section(s) $levels are open.";
$this->assertHasConnectionHandle();
$this->doBegin( $fname );
+ $this->trxShortId = sprintf( '%06x', mt_rand( 0, 0xffffff ) );
$this->trxStatus = self::STATUS_TRX_OK;
$this->trxStatusIgnoredCause = null;
$this->trxAtomicCounter = 0;
$this->trxDoneWrites = false;
$this->trxAutomaticAtomic = false;
$this->trxAtomicLevels = [];
- $this->trxShortId = sprintf( '%06x', mt_rand( 0, 0xffffff ) );
$this->trxWriteDuration = 0.0;
$this->trxWriteQueryCount = 0;
$this->trxWriteAffectedRows = 0;
*
* @see Database::begin()
* @param string $fname
+ * @throws DBError
*/
protected function doBegin( $fname ) {
$this->query( 'BEGIN', $fname );
- $this->trxLevel = 1;
}
final public function commit( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) {
throw new DBUnexpectedError( $this, "$fname: invalid flush parameter '$flush'." );
}
- if ( $this->trxLevel && $this->trxAtomicLevels ) {
+ if ( $this->trxLevel() && $this->trxAtomicLevels ) {
// There are still atomic sections open; this cannot be ignored
$levels = $this->flatAtomicSectionList();
throw new DBUnexpectedError(
}
if ( $flush === self::FLUSHING_INTERNAL || $flush === self::FLUSHING_ALL_PEERS ) {
- if ( !$this->trxLevel ) {
+ if ( !$this->trxLevel() ) {
return; // nothing to do
} elseif ( !$this->trxAutomatic ) {
throw new DBUnexpectedError(
"$fname: Flushing an explicit transaction, getting out of sync."
);
}
- } elseif ( !$this->trxLevel ) {
+ } elseif ( !$this->trxLevel() ) {
$this->queryLogger->error(
"$fname: No transaction to commit, something got out of sync." );
return; // nothing to do
$writeTime = $this->pendingWriteQueryDuration( self::ESTIMATE_DB_APPLY );
$this->doCommit( $fname );
+ $oldTrxShortId = $this->consumeTrxShortId();
$this->trxStatus = self::STATUS_TRX_NONE;
if ( $this->trxDoneWrites ) {
$this->trxProfiler->transactionWritingOut(
$this->server,
$this->getDomainID(),
- $this->trxShortId,
+ $oldTrxShortId,
$writeTime,
$this->trxWriteAffectedRows
);
*
* @see Database::commit()
* @param string $fname
+ * @throws DBError
*/
protected function doCommit( $fname ) {
- if ( $this->trxLevel ) {
+ if ( $this->trxLevel() ) {
$this->query( 'COMMIT', $fname );
- $this->trxLevel = 0;
}
}
final public function rollback( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) {
- $trxActive = $this->trxLevel;
+ $trxActive = $this->trxLevel();
if ( $flush !== self::FLUSHING_INTERNAL
&& $flush !== self::FLUSHING_ALL_PEERS
$this->assertHasConnectionHandle();
$this->doRollback( $fname );
+ $oldTrxShortId = $this->consumeTrxShortId();
$this->trxStatus = self::STATUS_TRX_NONE;
$this->trxAtomicLevels = [];
// Estimate the RTT via a query now that trxStatus is OK
$this->trxProfiler->transactionWritingOut(
$this->server,
$this->getDomainID(),
- $this->trxShortId,
+ $oldTrxShortId,
$writeTime,
$this->trxWriteAffectedRows
);
*
* @see Database::rollback()
* @param string $fname
+ * @throws DBError
*/
protected function doRollback( $fname ) {
- if ( $this->trxLevel ) {
+ if ( $this->trxLevel() ) {
# Disconnects cause rollback anyway, so ignore those errors
$ignoreErrors = true;
$this->query( 'ROLLBACK', $fname, $ignoreErrors );
- $this->trxLevel = 0;
}
}
}
public function explicitTrxActive() {
- return $this->trxLevel && ( $this->trxAtomicLevels || !$this->trxAutomatic );
+ return $this->trxLevel() && ( $this->trxAtomicLevels || !$this->trxAutomatic );
}
public function duplicateTableStructure(
* @since 1.27
*/
final protected function getRecordedTransactionLagStatus() {
- return ( $this->trxLevel && $this->trxReplicaLag !== null )
+ return ( $this->trxLevel() && $this->trxReplicaLag !== null )
? [ 'lag' => $this->trxReplicaLag, 'since' => $this->trxTimestamp() ]
: null;
}
* Run a few simple sanity checks and close dangling connections
*/
public function __destruct() {
- if ( $this->trxLevel && $this->trxDoneWrites ) {
+ if ( $this->trxLevel() && $this->trxDoneWrites ) {
trigger_error( "Uncommitted DB writes (transaction from {$this->trxFname})." );
}
namespace Wikimedia\Rdbms;
use Exception;
+use RuntimeException;
use stdClass;
use Wikimedia\AtEase\AtEase;
$this->query( 'ROLLBACK TRANSACTION ' . $this->addIdentifierQuotes( $identifier ), $fname );
}
- /**
- * Begin a transaction, committing any previously open transaction
- * @param string $fname
- */
protected function doBegin( $fname = __METHOD__ ) {
- sqlsrv_begin_transaction( $this->conn );
- $this->trxLevel = 1;
+ if ( !sqlsrv_begin_transaction( $this->conn ) ) {
+ $this->reportQueryError( $this->lastError(), $this->lastErrno(), 'BEGIN', $fname );
+ }
}
/**
* @param string $fname
*/
protected function doCommit( $fname = __METHOD__ ) {
- sqlsrv_commit( $this->conn );
- $this->trxLevel = 0;
+ if ( !sqlsrv_commit( $this->conn ) ) {
+ $this->reportQueryError( $this->lastError(), $this->lastErrno(), 'COMMIT', $fname );
+ }
}
/**
* @param string $fname
*/
protected function doRollback( $fname = __METHOD__ ) {
- sqlsrv_rollback( $this->conn );
- $this->trxLevel = 0;
+ if ( !sqlsrv_rollback( $this->conn ) ) {
+ $this->queryLogger->error(
+ "{fname}\t{db_server}\t{errno}\t{error}\t",
+ $this->getLogContext( [
+ 'errno' => $this->lastErrno(),
+ 'error' => $this->lastError(),
+ 'fname' => $fname,
+ 'trace' => ( new RuntimeException() )->getTraceAsString()
+ ] )
+ );
+ }
}
/**
* @param string $desiredSchema
*/
public function determineCoreSchema( $desiredSchema ) {
- if ( $this->trxLevel ) {
+ if ( $this->trxLevel() ) {
// We do not want the schema selection to change on ROLLBACK or INSERT SELECT.
// See https://www.postgresql.org/docs/8.3/sql-set.html
throw new DBUnexpectedError(
} else {
$this->query( 'BEGIN', $fname );
}
- $this->trxLevel = 1;
}
/**
$this->next();
- return is_object( $row ) ? (array)$row : $row;
+ return is_object( $row ) ? get_object_vars( $row ) : $row;
}
function seek( $pos ) {
*/
public function getWriterIndex();
- /**
- * Returns true if the specified index is a valid server index
- *
- * @param int $i
- * @return bool
- */
- public function haveIndex( $i );
-
- /**
- * Returns true if the specified index is valid and has non-zero load
- *
- * @param int $i
- * @return bool
- */
- public function isNonZeroLoad( $i );
-
/**
* Get the number of servers defined in configuration
*
return 0;
}
+ /**
+ * Returns true if the specified index is a valid server index
+ *
+ * @param int $i
+ * @return bool
+ * @deprecated Since 1.34
+ */
public function haveIndex( $i ) {
return array_key_exists( $i, $this->servers );
}
+ /**
+ * Returns true if the specified index is valid and has non-zero load
+ *
+ * @param int $i
+ * @return bool
+ * @deprecated Since 1.34
+ */
public function isNonZeroLoad( $i ) {
return array_key_exists( $i, $this->servers ) && $this->genericLoads[$i] != 0;
}
'specialmute-email-footer',
$specialMutePage->getCanonicalURL(),
$context->getUser()->getName()
- );
+ )->inContentLanguage()->text();
}
// Check and increment the rate limits
"history": "Historial de la páxina",
"history_short": "Historial",
"history_small": "historial",
- "updatedmarker": "anovada dende la mio visita cabera",
+ "updatedmarker": "anovada dende la to visita cabera",
"printableversion": "Versión pa imprentar",
"permalink": "Enllaz permanente",
"print": "Imprentar",
"viewsourcelink": "cingak wit",
"editsectionhint": "Uah pahan: $1",
"toc": "Daging",
- "showtoc": "edengang",
+ "showtoc": "sinahang",
"hidetoc": "engkebang",
"collapsible-expand": "buka",
"confirmable-confirm": "{{GENDER:$1|Jero}} yakin?",
"createacct-benefit-heading": "{{SITENAME}} kakaryanin olih anak sakadi jero.",
"createacct-benefit-body1": "{{PLURAL:$1|uahan}}",
"createacct-benefit-body2": "{{PLURAL:$1|kaca}}",
- "createacct-benefit-body3": "{{PLURAL:$1|sang anuut}} anyar",
+ "createacct-benefit-body3": "{{PLURAL:$1|sang anuut}} sané mangkin",
"mailmypassword": "nyumu ngaryanin kruna sandi",
"loginlanguagelabel": "Basa: $1",
"pt-login": "Manjing log",
"savearticle-start": "Raksa kaca...",
"publishpage-start": "Terbitang kaca…",
"preview": "tayangan sadurungnyane",
- "showpreview": "cingak sane lintang",
+ "showpreview": "Sinahang preview",
"showdiff": "Cingak uahan",
"anoneditwarning": "<strong>Pingetan:</strong> Ida dané nénten kacatet ngranjing. Alamat IP ida dané jagi kacatet ring sejarah (indik sané dumunan) ring lembar puniki. Yening ida dane <strong>[$1 log in]</strong> utawi <strong>[$2 create an account]</strong>, your edits will be attributed to your username, along with other benefits.",
"loginreqlink": "manjing log",
"history-feed-description": "Babad uahan kaca puniki ring wiki",
"history-feed-item-nocomment": "$1 ring $2",
"rev-delundel": "gentos pangatonan",
+ "rev-showdeleted": "sinahang",
"revdelete-hide-comment": "Uah ringkesan",
"revdel-restore": "gentos pangatonan",
"pagehist": "Babad kaca",
"prev-page": "kaca sadurungnyané",
"prevn-title": "$1 {{PLURAL:$1|asil}} sadurunge",
"nextn-title": "$1 {{PLURAL:$1|asil}} selanturnyane",
- "shown-title": "ngantenang $1{{PLURAL:$1|asil}} sabilang lembar",
+ "shown-title": "Sinahang $1 {{PLURAL:$1|asil}} per kaca",
"viewprevnext": "Cingak ($1 {{int:pipe-separator}}$2)($3)",
"searchmenu-exists": "wenten lembar sane mamurda \"[[:$1]]\" ring wiki puniki. {{PLURAL:$2|0=| cingakin taler asil rerehan lianan sane kapolihang}}",
"searchmenu-new": "<strong> ngawi lembar \"[[:$1]] ring wiki puniki </ strong>! {{{{PLURAL:$2|}}| 0 = | cingak teler lembar sane kapolihang ring pangreregan | cingak taler asil pangrerehan sane kapolihang}}",
"searchprofile-images": "multimedia",
"searchprofile-everything": "Samian",
"searchprofile-advanced": "lanturane",
- "searchprofile-articles-tooltip": "ngarereh ring $1",
+ "searchprofile-articles-tooltip": "Rereh ring $1",
"searchprofile-images-tooltip": "Rereh berkas",
"searchprofile-everything-tooltip": "pangrereh ring samian isi (taler lembar wecana)",
"searchprofile-advanced-tooltip": "pangrereh ring genah pesengan sane kasinahang",
"action-editsemiprotected": "uah kaca sané kasaibin \"{{int:protect-level-autoconfirmed}}\"",
"nchanges": "$1{{PLURAL:$1|panguwahan|uwah-uwahan}}",
"enhancedrc-history": "babad",
- "recentchanges": "Uahan anyar",
- "recentchanges-legend": "pilihan panguwahan sane anyar",
+ "recentchanges": "Uahan sané mangkin",
+ "recentchanges-legend": "Opsi uahan sané mangkin",
+ "recentchanges-summary": "Track uahan sané mangkin ring wikiné indik kaca puniki.",
"recentchanges-feed-description": "molihang pagentosan anyar ring wiki ring \"umpan\" puniki",
"recentchanges-label-newpage": "Uahan puniki makarya kaca anyar",
"recentchanges-label-minor": "Punika uahan alit",
"recentchanges-label-bot": "penguwahan puniki kalaksanayang antuk bot",
"recentchanges-label-unpatrolled": "Uahan puniki durung kapatroli",
"recentchanges-legend-newpage": "{{int:recentchanges-label-newpage}} (taler cingak [[Special:NewPages|bacakan kaca anyar]])",
+ "recentchanges-submit": "Sinahang",
+ "rcfilters-activefilters-show": "Sinahang",
"rcfilters-savedqueries-remove": "Usap",
"rcfilters-filter-minor-label": "Uahan alit",
"rcfilters-filter-major-label": "Uahan tan alit",
"rcnotefrom": "Ring beten puniki inggih punika {{PLURAL:$5|panguwahan}} saking <strong>$3, $4</strong> (kaedengang ngantos <strong>$1</strong> panguwahan).",
"rclistfrom": "edengang penguwahan sane anyar wit saking $3 $2",
"rcshowhideminor": "$1 uahan alit",
- "rcshowhideminor-show": "Edengang",
+ "rcshowhideminor-show": "Sinahang",
"rcshowhideminor-hide": "Engkebang",
"rcshowhidebots": "$1 bot",
- "rcshowhidebots-show": "Edengang",
+ "rcshowhidebots-show": "Sinahang",
"rcshowhidebots-hide": "Engkebang",
"rcshowhideliu": "$1 sang anganggé madaptar",
- "rcshowhideliu-show": "Edengang",
+ "rcshowhideliu-show": "Sinahang",
"rcshowhideliu-hide": "engkebang",
"rcshowhideanons": "$1 sang anganggé tan kauningin",
- "rcshowhideanons-show": "Edengang",
+ "rcshowhideanons-show": "Sinahang",
"rcshowhideanons-hide": "Engkebang",
- "rcshowhidepatr": "$1 suntingan sane kapatroli",
+ "rcshowhidepatr": "$1 uahan sané kapatroli",
+ "rcshowhidepatr-show": "Sinahang",
"rcshowhidemine": "$1 uahan titiang",
- "rcshowhidemine-show": "Edengang",
+ "rcshowhidemine-show": "Sinahang",
"rcshowhidemine-hide": "Engkebang",
+ "rcshowhidecategorization-show": "Sinahang",
"rclinks": "Edengang untat $1 gentosan anyar $2 dina kaping untat",
"diff": "bina",
"hist": "bbd",
"hide": "engkebang",
- "show": "edengang",
+ "show": "Sinahang",
"minoreditletter": "a",
"newpageletter": "A",
"boteditletter": "b",
"rc-enhanced-expand": "edengang rerincian",
"rc-enhanced-hide": "engkebang rerincian",
"rc-old-title": "witnyané kakaryanin pinaka \"$1\"",
- "recentchangeslinked": "pangentos sane wenten paiketane",
- "recentchangeslinked-toolbox": "pangentos sane wenten paiketane",
- "recentchangeslinked-title": "panguwahan sane mapaiketan ring $1",
+ "recentchangeslinked": "Uahan mapaiketan",
+ "recentchangeslinked-toolbox": "Uahan mapaiketan",
+ "recentchangeslinked-title": "Uahan sané mapaiketan $1",
"recentchangeslinked-summary": "lembar kautamayang puniki ngicenin kepahan penguwahan kaping untat ring lembar-lembar sana mapaiket. Lembar sane [[Special:Watchlist|ida dane iwasin]] mapinget antuk sesuratan tebel",
"recentchangeslinked-page": "Peséngan kaca:",
- "recentchangeslinked-to": "edengang panguwahan sakin lembar-lembar sane mapaiket antuk lembar-lembar sane kaedengang",
- "upload": "ngunggahang berkas",
- "uploadlogpage": "Log pangunggahan",
+ "recentchangeslinked-to": "Sinahang uahan saking kaca-kaca sané linked kaca puniki",
+ "upload": "Unggahang berkas",
+ "uploadlogpage": "Log unggahan",
"filedesc": "Ringkesan",
"savefile": "Raksa berkas",
"upload-dialog-button-save": "Raksa",
+ "backend-fail-delete": "Tan prasida ngusapin berkas \"$1\".",
"license": "kepahan lugra",
"license-header": "kepahan lugra",
"listfiles-delete": "usap",
"filehist-user": "Sang anganggé",
"filehist-dimensions": "ukuran",
"filehist-comment": "tureksa",
- "imagelinks": "penganggen berkas",
+ "imagelinks": "Panganggén berkas",
"linkstoimage": "nyarengin {{PLURAL:$1|pranala|$1pranala}} ring pupulan puniki",
"nolinkstoimage": "Nénten wénten kaca sané nganggén berkas puniki.",
"sharedupload-desc-here": "pupulan puniki mawit saking $1 lan minab kaanggen olih proyek-proyek sane lianan. Deskripsi saking [$2 lebar deskripsinyane] kaarahin ring ungkur puniki",
"upload-disallowed-here": "Jero nénten dados numpuk berkas puniki.",
"filedelete": "Usap $1",
"filedelete-submit": "Usap",
+ "filedelete-success": "<strong>$1</strong> sampun kausapin.",
"filedelete-maintenance-title": "Nénten prasida ngusapin berkas",
"randompage": "Kaca punapi kémanten",
"statistics": "Statistik",
"statistics-articles": "Kaca daging",
"brokenredirects-edit": "uah",
"brokenredirects-delete": "usap",
+ "withoutinterwiki-submit": "Sinahang",
"nbytes": "$1{{PLURAL:$1|bita}}",
"nmembers": "$1 {{PLURAL:$1|krama}}",
"prefixindex": "Makasami kaca sané mapangater",
+ "prefixindex-submit": "Sinahang",
"protectedpages": "Kaca sané kasaibin",
"protectedpages-page": "Kaca",
"protectedpages-performer": "Sang anganggé sané nyaibin",
"usereditcount": "$1 {{PLURAL:$1|uahan}}",
"usercreated": "{{GENDER:$3|kakaryanin}} ring $1 galah $2",
"newpages": "Kaca anyar",
+ "newpages-submit": "Sinahang",
"move": "Gingsirang",
"pager-newer-n": "{{PLURAL:$1|1 lewih anyar|$1 lewih anyar}}",
"pager-older-n": "{{PLURAL:$1|1 lewih suwe|$1 lewih anyar}}",
"booksources-search-legend": "Rereh wit buku",
"booksources-search": "Rereh",
"log": "Log",
+ "logeventslist-submit": "Sinahang",
"all-logs-page": "Makasami log publik",
"allpages": "Makasami kaca",
"allarticles": "Makasami kaca",
"allpagessubmit": "lanturang",
"categories": "Golongan",
+ "categories-submit": "Sinahang",
"deletedcontributions": "Pituut sang anganggé sané kausapin",
"linksearch-line": "$1 masambung saking $2",
+ "listusers-submit": "Sinahang",
"listgrouprights-members": "kepahan krama",
"emailuser": "email sane nganggo niki",
"watchlist": "kepahan peninjoan",
"watch": "cingak",
"unwatch": "tan sida maninjo",
"watchlist-details": "{{PLURAL:$1|$1 lembar}} ring paninjoan ida dane, nenten sareng lembar wacana.",
- "wlshowlast": "Cingak $1 jam $2 rahina sané lintang",
+ "wlshowlast": "Sinahang $1 jam $2 rahina sané lintang",
+ "watchlist-submit": "Sinahang",
"wlshowhideminor": "uahan alit",
"watchlist-options": "milih kepahan peninjo",
"enotif_subject_deleted": "Kaca {{SITENAME}} $1 sampun {{GENDER:$2|kausap}} $2",
"enotif_body_intro_deleted": "Kaca{{SITENAME}} $1 sampun {{GENDER:$2|kausapin}} ring $PAGEEDITDATE olih $2, cingak $3.",
"deletepage": "Usap kaca",
"delete-confirm": "Usap \"$1\"",
+ "historyaction-submit": "Sinahang uahan",
"actioncomplete": "pelaksanan sampun wusan",
"actionfailed": "pelaksana luput",
"dellogpage": "log pangapus",
"sp-contributions-newbies": "Cingak pituut wantah saking akun anyar",
"sp-contributions-blocklog": "log pemblokiran",
"sp-contributions-deleted": "pituut {{GENDER:$1|sang anganggé}} sané kausapin",
- "sp-contributions-uploads": "unggahang",
+ "sp-contributions-uploads": "unggahan",
"sp-contributions-logs": "log",
"sp-contributions-talk": "pabligbagan",
"sp-contributions-search": "Rereh pituut",
"whatlinkshere-filters": "Panyaring",
"ipboptions": "2 jam:2 hours,1 dina:1 day,3 dina:3 days,1 minggu:1 week,2 minggu:2 weeks,1 sasih:1 month,3 sasih:3 months,6 sasih:6 months,1 taun:1 year,tanpa wates:infinite",
"ipb-pages-label": "Kaca",
+ "block-prevent-edit": "Nguahin",
"ipblocklist": "ngempetin sane nganggo",
"blocklist-nousertalk": "tan prasida nguahin kaca pabligbagan praragan",
"blocklist-editing-page": "kaca",
"tooltip-pt-watchlist": "kepahan-kepahan lembar sane katinjo titiang",
"tooltip-pt-mycontris": "Bacakan pituut {{GENDER:|jero}}",
"tooltip-pt-login": "Jero kaaptiang mangda manjing log; yadiastun nénten wajib",
- "tooltip-pt-logout": "medal saking Log",
+ "tooltip-pt-logout": "Medal log",
"tooltip-pt-createaccount": "Jero kaaptiang mangda makarya akun miwah manjing log; yadiastun nénten wajib",
"tooltip-ca-talk": "Pabligbagan indik kaca daging",
"tooltip-ca-edit": "Uah kaca puniki",
"tooltip-n-mainpage-description": "Cingak kaca utama",
"tooltip-n-portal": "Indik proyék, sané prasida kalaksanayang, genah ngrereh wantuan",
"tooltip-n-currentevents": "molihang warta indik kawentenan kawentenan sane pinih anyar",
- "tooltip-n-recentchanges": "Bacakan uahan anyar ring wiki",
+ "tooltip-n-recentchanges": "Bacakan uahan sané mangkin ring wiki",
"tooltip-n-randompage": "Cihnayang kaca napi kémanten",
"tooltip-n-help": "Genah ngrereh wantuan",
"tooltip-t-whatlinkshere": "Bacakan makasami kaca ring wiki sané nuju iriki",
- "tooltip-t-recentchangeslinked": "Pagentosan anyar lembar sane maduwe pranala nuju lembar puniki",
+ "tooltip-t-recentchangeslinked": "Uahan sané mangkin saking kaca-kaca sané linked ring kaca puniki",
"tooltip-feed-atom": "\"atom feed\" anggen lembar puniki",
"tooltip-t-contributions": "Bacakan pituut olih {{GENDER:$1|sang anganggé puniki}}",
"tooltip-t-emailuser": "Ngirim surel majeng ring {{GENDER:$1|penganggo puniki}}",
"tooltip-minoredit": "pingetin puniki dados panguwahan kidik",
"tooltip-save": "Raksa uahan jero",
"tooltip-preview": "Pagentosan sane dumun duwen ida dane, mangda anggen niki sadurung jagi nyimpen!",
- "tooltip-diff": "Cingak uahan sané karyanin jero ring suratannyané",
+ "tooltip-diff": "Sinahang uahan sané karyanin jero ring sesuratannyané",
"tooltip-compareselectedversions": "cingak binane makekalih kepahan lembar sane kasudi",
"tooltip-watch": "imbuhin lembar niki ring daftar paninjoan ida dane",
"tooltip-rollback": "\"nguliang\" muwungan jagi ngabecikang ring lembar puniki nuju haturan sane untat ngangge apisan klik",
"tags-active-no": "Nénten",
"tags-edit": "uah",
"tags-delete": "usap",
+ "tags-delete-title": "Usap tag",
"compare-page2": "Kaca 2",
"logentry-delete-delete": "$1 {{GENDER:$2|ngusapin}} kaca $3",
"logentry-move-move": "$1 {{GENDER:$2|ngingsirang}} kaca $3 ring $4",
"logentry-newusers-create": "Akun sang anganggé $1 {{GENDER:$2|kakaryanin}}",
"logentry-newusers-autocreate": "Akun sang anganggé $1 {{GENDER:$2|kakaryanin}} otomatis",
"logentry-protect-protect": "$1 {{GENDER:$2|nyaibin}} $3 $4",
+ "logentry-upload-upload": "$1 {{GENDER:$2|ngunggahang}} $3",
+ "logentry-upload-overwrite": "$1 {{GENDER:$2|ngunggahang}} vèrsi anyar saking $3",
"searchsuggest-search": "Rereh ring {{SITENAME}}",
"duration-days": "$1 {{PLURAL:$1|rahina}}",
"pagelanguage": "Uah basa ring kaca",
"edit-error-short": "Памылка: $1",
"edit-error-long": "Памылкі:\n\n$1",
"specialmute": "Заглушаныя ўдзельнікі",
+ "specialmute-success": "Вашыя налады заглушэньня былі пасьпяхова абноўленыя. Глядзіце ўсіх заглушаных удзельнікаў на старонцы [[Special:Preferences]].",
+ "specialmute-submit": "Пацьвердзіць",
"revid": "вэрсія $1",
"pageid": "Ідэнтыфікатар старонкі $1",
"interfaceadmin-info": "$1\n\nДазволы на рэдагаваньне агульнасайтавых CSS/JS/JSON-файлаў былі нядаўна вылучаныя з права <code>editinterface</code>. Калі вы не разумееце, чаму атрымліваеце гэтую памылку, глядзіце [[mw:MediaWiki_1.32/interface-admin]].",
"history": "পাতার ইতিহাস",
"history_short": "ইতিহাস",
"history_small": "ইতিহাস",
- "updatedmarker": "à¦\86মার শেষ পরিদর্শনের পর থেকে হালনাগাদকৃত",
+ "updatedmarker": "à¦\86পনার শেষ পরিদর্শনের পর থেকে হালনাগাদকৃত",
"printableversion": "ছাপার যোগ্য সংস্করণ",
"permalink": "স্থায়ী সংযোগ",
"print": "মুদ্রণ",
"restrictionsfield-help": "লাইন প্রতি একটি আইপি ঠিকানা বা CIDR পরিসীমা। সবকিছু সক্রিয় করতে ব্যবহার করুন: :<pre>0.0.0.0/0\n::/0</pre>",
"edit-error-short": "ত্রুটি: $1",
"edit-error-long": "ত্রুটিসমূহ:\n\n$1",
+ "specialmute-submit": "নিশ্চিত করুন",
"revid": "সংশোধন $1",
"pageid": "পাতার আইডি $1",
"rawhtml-notallowed": "<html> ট্যাগ স্বাভাবিক পৃষ্ঠাগুলির বাহিরে ব্যবহার করা যাবে না।",
"passwordpolicies-policy-maximalpasswordlength": "পাসওয়ার্ড $1 {{PLURAL:$1|অক্ষরের}} চেয়ে কম দীর্ঘ হতে হবে",
"passwordpolicies-policy-passwordnotinlargeblacklist": "পাসওয়ার্ড ১,০০,০০০ সর্বাধিক ব্যবহৃত পাসওয়ার্ডের তালিকায় থাকতে পারবে না।",
"unprotected-js": "নিরাপত্তার কারণে জাভাস্ক্রিপ্ট অনিরাপদ পৃষ্ঠা থেকে লোড করা যাবে না। শুধুমাত্র মিডিয়াউইকি: নামস্থান বা ব্যবহারকারী উপপাতায় জাভাস্ক্রিপ্ট তৈরি করুন",
- "userlogout-continue": "à¦\86পনি যদি পà§\8dরসà§\8dথান à¦\95রতà§\87 à¦\9aান দয়া à¦\95রà§\87 [$1 পà§\8dরসà§\8dথান পাতায় যান]।"
+ "userlogout-continue": "à¦\86পনি à¦\95ি পà§\8dরসà§\8dথান à¦\95রতà§\87 à¦\9aান?"
}
"movethispage": "Trasllada la pàgina",
"unusedimagestext": "Els següents fitxers existeixen però estan incorporats en cap altra pàgina.\nTingueu en compte que altres llocs web poden enllaçar un fitxer amb un URL directe i estar llistat ací tot i estar en ús actiu.",
"unusedcategoriestext": "Les pàgines de categoria següents existeixen encara que cap altra pàgina o categoria les utilitza.",
- "notargettitle": "No hi ha pàgina en blanc",
+ "notargettitle": "No hi ha cap objectiu",
"notargettext": "No heu especificat a quina pàgina dur a terme aquesta funció.",
"nopagetitle": "No existeix aquesta pàgina",
"nopagetext": "La pàgina que heu especificat no existeix.",
"restrictionsfield-label": "Intervals d'IP permesos:",
"edit-error-short": "Error: $1",
"edit-error-long": "Errors:\n\n$1",
+ "specialmute-submit": "Confirma",
+ "specialmute-error-invalid-user": "No s’ha trobat el nom d’usuari que heu indicat.",
"revid": "revisió $1",
"pageid": "ID de pàgina $1",
"rawhtml-notallowed": "No és possible fer servir les etiquetes <html> fora de les pàgines normals.",
"history": "Hanes y dudalen",
"history_short": "Hanes",
"history_small": "hanes",
- "updatedmarker": "diwygiwyd ers i mi ymweld ddiwethaf",
+ "updatedmarker": "diwygiwyd ers eich ymweliad ddiwethaf",
"printableversion": "Fersiwn argraffu",
"permalink": "Dolen barhaol",
"print": "Argraffu",
"badarticleerror": "Mae'n amhosib cyflawni'r weithred hon ar y dudalen hon.",
"cannotdelete": "Mae'n amhosib dileu'r dudalen neu'r ddelwedd \"$1\".\nEfallai fod rhywun arall eisoes wedi'i dileu.",
"cannotdelete-title": "Ni ellir dileu'r dudalen '$1'",
+ "delete-scheduled": "Bydd \"$1\" yn cael ei dileu.\nMam inni yw amynedd!",
"delete-hook-aborted": "Terfynwyd y dilead cyn pryd gan fachyn.\nNi roddodd eglurhad.",
"no-null-revision": "Ni lwyddwyd i wneud diwygiad newydd heb unrhyw newid ynddo, i'r dudalen \"$1\"",
"badtitle": "Teitl gwael",
"cascadeprotected": "Diogelwyd y ddalen hon rhag ei newid, oherwydd ei bod wedi ei chynnwys yn y {{PLURAL:$1|ddalen ganlynol|dalennau canlynol}}, a ddiogelwyd, gyda'r dewisiad hwn yn weithredol: $2",
"namespaceprotected": "Nid oes caniatâd gennych i olygu tudalennau yn y parth '''$1'''.",
"customcssprotected": "Nid oes caniatâd ganddoch i olygu'r dudalen CSS hon oherwydd bod gosodiadau personol defnyddiwr arall arno.",
+ "customjsonprotected": "Nid oes gennych yr hawl i olygu tudalen JASON, gan ei bod yn cynnwys dewisiadau (settings) defnyddiwr arall.",
"customjsprotected": "Nid oes caniatâd ganddoch i olygu'r dudalen JavaScript hon oherwydd bod gosodiadau personol defnyddiwr arall arno.",
+ "sitecssprotected": "Nid oes gennych yr hawl i olygu tudalen CSS, gan y gall effeithio pob ymwelydd.",
+ "sitejsonprotected": "Nid oes gennych yr hawl i olygu tudalen JASON yma, gan y gall effeithio pob ymwelydd.",
+ "sitejsprotected": "Nid oes gennych yr hawl i olygu'r dudalen JASON yma gan y gall effeithio pob ymwelydd.",
"mycustomcssprotected": "Does dim caniatad gennych i olygu'r dudalen CSS hon.",
+ "mycustomjsonprotected": "Nid oes gennych yr hawl i olygu tudalen JASON yma.",
"mycustomjsprotected": "Does dim caniatad gennych i olygu'r dudalen JavaScript hon.",
"myprivateinfoprotected": "Nid oes caniatad gennych i olygu eich manylion personol preifat.",
"mypreferencesprotected": "Nid oes caniatad gennych i olygu eich dewisiadau eich hunan.",
"virus-scanfailed": "methodd y sgan (côd $1)",
"virus-unknownscanner": "gwrthfirysydd anhysbys:",
"logouttext": "'''Rydych wedi allgofnodi.'''\n\nSylwer y bydd rhai tudalennau yn parhau i ymddangos fel ag yr oeddent pan oeddech wedi mewngofnodi hyd nes i chi glirio celc eich porwr.",
+ "logging-out-notify": "Rydych yn cael eich hallgofnodi, rhowswch funud...",
+ "logout-failed": "Methu allgofnodi ar hyn o bryd: $1",
"cannotlogoutnow-title": "Ni ellir allgofnodi ar hyn o bryd",
"cannotlogoutnow-text": "Ni ellir allgofnodi tra'n defnyddio $1.",
"welcomeuser": "Croeso, $1!",
"badretype": "Nid yw'r cyfrineiriau'n union yr un fath.",
"usernameinprogress": "Mae creu cyfrif i'r enw-defnyddiwr hwn wrthi'n cael ei brosesu. Daliwch eich gafael!",
"userexists": "Mae rhywun arall wedi dewis yr enw defnyddiwr hwn. \nDewiswch un arall os gwelwch yn dda.",
+ "createacct-normalization": "Oherwydd cyfyngiadau technegol, bydd eich enw defnyddiwr yn cael ei ailenwi yn \"$2\".",
"loginerror": "Problem mewngofnodi",
"createacct-error": "Nam wrth greu cyfrif",
"createaccounterror": "Ni lwyddwyd i greu'r cyfrif: $1",
"passwordtooshort": "Mae'n rhaid fod gan gyfrinair o leia $1 {{PLURAL:$1|nod}}.",
"passwordtoolong": "Ni chaiff cyfrinair fod yn hirach na {{PLURAL:$1|1 llythyren|$1 llythyren}}.",
"passwordtoopopular": "Chewch chi ddim defnyddio cyfrineiriau cyffredin. Dewisiwch un unigryw a gwahanol!",
+ "passwordinlargeblacklist": "Mae'r cyfrinair yma ar restr o rai sy'n rhy gyffredin o'r hanner. dewisiwch un mwy unigryw, gwahanol.",
"password-name-match": "Rhaid i'ch cyfrinair a'ch enw defnyddiwr fod yn wahanol i'w gilydd.",
"password-login-forbidden": "Gwaharddwyd defnyddio'r enw defnyddiwr a'r cyfrinair hwn.",
"mailmypassword": "Ailosoder y cyfrinair",
"changepassword-success": "Newidiwyd eich cyfrinair!",
"changepassword-throttled": "Rydych wedi ceisio logio mewn yn rhy aml.\nArhoswch am $1 cyn trio eto.",
"botpasswords": "Cyfrineiriau bots",
+ "botpasswords-disabled": "Ni ellir creu cyfrinair gyda bot.",
"botpasswords-label-appid": "Enw bot:",
"botpasswords-label-create": "Dechrau",
"botpasswords-label-update": "Diweddaru",
"editpage-invalidcontentmodel-text": "Nid yw'r model \"$1\" ar gael.",
"editpage-notsupportedcontentformat-title": "Dydy fformat y cynnwys hwn ddim yn cael ei gefnogi gennym.",
"editpage-notsupportedcontentformat-text": "Dydy'r fformat $1 ar y cynnwys ddim yn cael ei gefnogi gan y model $2.",
+ "slot-name-main": "Prif",
"content-model-wikitext": "cystrawen wici",
"content-model-text": "testun plaen",
"content-model-javascript": "JavaScript",
"content-model-css": "CSS",
"content-json-empty-object": "Dim gwrthrych",
"content-json-empty-array": "Rhesi gwag",
+ "deprecated-self-close-category": "Tudalennau gyda tagiau HTML annilys.",
"duplicate-args-warning": "<strong>Rhybudd:</strong> Mae [[:$1]] yn galw [[:$2]] gyda mwy nag un gwerthrif (''value'') i baramedr \"$3\". Dim ond y gwerthrif diwethaf gaiff ei ddefnyddio.",
"duplicate-args-category": "Tudalennau gyda meysydd deublyg yn y Nodion",
"duplicate-args-category-desc": "Mae'r dudalen hon yn cynnwys meysydd yn y Nodion, ddwy waith e.e. <code><nowiki>{{foo|bar=1|bar=2}}</nowiki></code> neu <code><nowiki>{{foo|bar|1=baz}}</nowiki></code>.",
"page_first": "cyntaf",
"page_last": "olaf",
"histlegend": "Cymharu dau fersiwn: marciwch y cylchoedd ar y ddau fersiwn i'w cymharu, yna pwyswch ar 'return' neu'r botwm 'Cymharer y fersiynau dewisedig'.<br />\nEglurhad: '''({{int:cur}})''' = gwahaniaethau rhyngddo a'r fersiwn cyfredol,\n'''({{int:last}})''' = gwahaniaethau rhyngddo a'r fersiwn cynt, '''({{int:minoreditletter}})''' = golygiad bychan",
- "history-fieldset-title": "Chwilio drwy'r hanes",
+ "history-fieldset-title": "Hidlo'r adolygiadau",
"history-show-deleted": "Dangos y rhai a ddilëwyd yn unig",
"histfirst": "cynharaf",
"histlast": "diweddaraf",
"diff-multi-manyusers": "(Ni ddangosir {{PLURAL:$1|yr $1 diwygiad|yr $1 diwygiad|y $1 ddiwygiad|y $1 diwygiad|y $1 diwygiad|y $1 diwygiad}} rhyngol gan mwy na $2 {{PLURAL:$2|o ddefnyddwyr}}.)",
"difference-missing-revision": "Ni chafwyd hyd i $1 {{PLURAL:$2|diwygiad|diwygiad|ddiwygiad|diwygiad}} o'r gwahaniaeth ($1) {{PLURAL:$2|hwn}}.\n\nFel arfer, fe ddigwydd hyn pan mae person wedi dilyn hen gyswllt gwahaniaeth i dudalen sydd erbyn hyn wedi cael ei ddileu.\nMae manylion pellach i'w cael yn [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} lòg y dileuon].",
"searchresults": "Canlyniadau'r chwiliad",
+ "search-filter-title-prefix-reset": "Chwilio pob tudalen",
"searchresults-title": "Canlyniadau chwilio am \"$1\"",
"titlematches": "Teitlau erthygl yn cyfateb",
"textmatches": "Testun erthygl yn cyfateb",
"action-applychangetags": "rhowch y tagiau ar waith, gyda'ch newidiadau",
"action-deletechangetags": "dilewch tagiau o'r gronfa ddata",
"action-purge": "carthwch y ddalen",
+ "action-editinterface": "golygwch y rhyngwyneb",
+ "action-unblockself": "dadflocio eich hunan",
"nchanges": "$1 {{PLURAL:$1|newid|newid|newid|newid|newid|o newidiadau}}",
"enhancedrc-since-last-visit": "$1 {{PLURAL:$1|ers eich ymweliad diwethaf}}",
"enhancedrc-history": "hanes",
"recentchanges-legend-newpage": "{{int:recentchanges-label-newpage}} (gweler hefyd [[Special:NewPages|restr y tudalennau newydd]])",
"recentchanges-legend-plusminus": "(''±123'')",
"recentchanges-submit": "Dangos",
+ "rcfilters-tag-remove": "Dileu '$1'",
"rcfilters-legend-heading": "<strong>Rhestr o fyrfoddau:</strong>",
"rcfilters-other-review-tools": "Teclynau adolygu eraill",
"rcfilters-group-results-by-page": "Canlyniadau'r grwp bob yn ddalen",
"rcfilters-activefilters": "Hidlau sydd ar waith",
"rcfilters-activefilters-hide": "Cuddio",
+ "rcfilters-activefilters-show": "Dangos",
"rcfilters-advancedfilters": "Ffiltrau ychwanegol",
"rcfilters-limit-title": "Canlyniadau a ddangosir",
"rcfilters-date-popup-title": "Cyfnod (i'w chwilio)",
"rcfilters-savedqueries-rename": "Ailenwi",
"rcfilters-savedqueries-setdefault": "Gosod yn ddiofyn (''Set as default'')",
"rcfilters-savedqueries-unsetdefault": "Diddymu fel gweithred ddiofyn (''Remove as default'')",
- "rcfilters-savedqueries-remove": "Cael gwared",
+ "rcfilters-savedqueries-remove": "Dileu",
"rcfilters-savedqueries-new-name-label": "Enw",
"rcfilters-savedqueries-new-name-placeholder": "Disgrifiwch bwrpas y ffiltr",
"rcfilters-savedqueries-apply-label": "Crewch ffiltr",
"rcfilters-restore-default-filters": "Ailosodwch y ffiltrau di-ofyn",
"rcfilters-clear-all-filters": "Cliriwch yr holl hidlau (ffiltrau)",
- "rcfilters-search-placeholder": "Ffiltrwch y newidiadau diweddaraf",
+ "rcfilters-search-placeholder": "FNewidiadau'r hidl (ffiltr) - defnyddiwch y blwch chwilio",
"rcfilters-invalid-filter": "Hidl annilys",
"rcfilters-empty-filter": "Dim hidlau ar waith",
"rcfilters-filterlist-title": "Hidlau (ffiltrau)",
- "rcfilters-filterlist-feedbacklink": "Rhowch adborth ar yr hidlau beta",
+ "rcfilters-filterlist-whatsthis": "Sut mae'r rhain yn gweithio?",
+ "rcfilters-filterlist-feedbacklink": "Rhowch adborth ar y teclynau hidlo",
"rcfilters-highlightbutton-title": "Amlygwch y canlyniadau",
"rcfilters-highlightmenu-title": "Dewisiwch liw",
"rcfilters-highlightmenu-help": "Dewisiwch liw sy'n cyd-fynd gyda'r nodwedd hon",
"rcfilters-filter-user-experience-level-unregistered-label": "Heb gofrestru",
"rcfilters-filter-user-experience-level-unregistered-description": "Golygyddion nad ydynt wedi cofrestru.",
"rcfilters-filter-user-experience-level-newcomer-label": "Newydd-ddyfodiaid",
- "rcfilters-filter-user-experience-level-newcomer-description": "Defnyddwyr cofrestredig gyda llai na 10 golygiad a 4 diwrnod o weithgaredd.",
+ "rcfilters-filter-user-experience-level-newcomer-description": "Defnyddwyr cofrestredig gyda llai na 10 golygiad neu 4 diwrnod o weithgaredd.",
"rcfilters-filter-user-experience-level-learner-label": "Dysgwyr",
"rcfilters-filter-user-experience-level-learner-description": "Defnyddwyr cofrestredig ble mae eu profiad yn syrthio rhwng \"Newydd-ddyfodiaid\" a \"Defnyddwyr profiadol.\"",
"rcfilters-filter-user-experience-level-experienced-label": "Defnyddwyr profiadol",
"rcfilters-filter-humans-description": "Golygiadau a wnaed gan olygyddion go-iawn.",
"rcfilters-filtergroup-reviewstatus": "Statws adolygu",
"rcfilters-filter-reviewstatus-unpatrolled-label": "Heb ei gadarnhau (''Unpatrolled'')",
+ "rcfilters-filter-reviewstatus-manual-label": "Patrol gyda llaw a llygad",
+ "rcfilters-filter-reviewstatus-auto-label": "Patroliwyd yn otomatig",
"rcfilters-filtergroup-significance": "Arwyddocaol",
"rcfilters-filter-minor-label": "Golygiadau bach",
"rcfilters-filter-minor-description": "Golygiadau a nodwyd gan y golygydd fel rhai bach.",
"rcfilters-filter-watchlist-watched-label": "Ar y Rhestr Wylio",
"rcfilters-filter-watchlist-watched-description": "Newidiadau i'r dalennau yn eich Rhestr Wylio.",
"rcfilters-filter-watchlist-watchednew-label": "Newidiadau newydd i'ch Rhestr Wylio",
+ "rcfilters-filter-watchlist-watchednew-description": "Newidiadau yn y Rhestr wylio nad ydych wedi ymweld a nhw ers i'r newidiadau gael eu gwneud.",
"rcfilters-filter-watchlist-notwatched-label": "Heb fod yn eich Rhestr Wylio",
"rcfilters-filter-watchlist-notwatched-description": "Popeth ar wahan i'r newidiadau i'ch Rhestr Wylio.",
"rcfilters-filter-watchlistactivity-unseen-label": "Newidiadau heb eu gweld gennych",
"uploadstash-bad-path-invalid": "'Dyw'r llwybr ddim yn gywir.",
"invalid-chunk-offset": "Atred annilys i'r talpiau",
"img-auth-accessdenied": "Ni chaniatawyd mynediad",
- "img-auth-nopathinfo": "PATH_INFO yn eisiau.\nNid yw'ch gweinydd wedi ei osod i fedru pasio'r wybodaeth hon.\nEfallai ei fod wedi ei seilio ar CGI, ac heb fod yn gallu cynnal img_auth.\nGweler https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Image_Authorization.",
+ "img-auth-nopathinfo": "Gwybodaeth am y llwybr yn eisiau.\nNid yw'ch gweinydd wedi ei osod i fedru pasio'r REQUEST_URI a/neu PATH_INFO.\nOs ydyw yna trowch y $wgUsePathInfo ymlaen.\n\nGweler https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Image_Authorization.",
"img-auth-notindir": "Nid yw'r llwybr y gwneuthpwyd cais amdano yn y cyfeiriadur uwchlwytho ffurfweddedig.",
"img-auth-badtitle": "Ddim yn gallu gwneud teitl dilys o \"$1\".",
"img-auth-nofile": "Nid oes ffeil a'r enw \"$1\" ar gael.",
"http-timed-out": "Goroedi wedi digwydd ar y cais HTTP.",
"http-curl-error": "Cafwyd gwall wrth nôl yr URL: $1",
"http-bad-status": "Cafwyd trafferth yn ystod y cais HTTP: $1 $2",
+ "http-internal-error": "Gwall mewnol HTTP.",
"upload-curl-error6": "Wedi methu cyrraedd yr URL",
"upload-curl-error6-text": "Ni chyrhaeddwyd yr URL a roddwyd.\nGwiriwch yr URL a sicrhau bod y wefan ar waith.",
"upload-curl-error28": "Goroedi wrth uwchlwytho",
"prefixindex": "Pob tudalen yn ôl parth",
"prefixindex-namespace": "Pob tudalen â rhagddodiad penodol (y parth $1)",
"prefixindex-submit": "Dangos",
- "prefixindex-strip": "Diosg y rhagddodiad wrth restru",
+ "prefixindex-strip": "Cuddio'r rhagddodiad yn y canfyddiadau",
"shortpages": "Erthyglau byr",
"longpages": "Tudalennau hirion",
"deadendpages": "Tudalennau heb gysylltiadau ynddynt",
"apisandbox-dynamic-parameters": "Paramedrau ychwanegol",
"apisandbox-dynamic-parameters-add-label": "Ychwanegu paramedrau",
"apisandbox-dynamic-parameters-add-placeholder": "Enw'r paramedr",
+ "apisandbox-add-multi": "Ychwanegu",
"apisandbox-results": "Canlyniadau",
"apisandbox-continue": "Parhau",
"apisandbox-continue-clear": "Clirio",
"speciallogtitlelabel": "Targed (teitl neu {{ns:user}}:username ar gyfer y defnyddiwr):",
"log": "Logiau",
"logeventslist-submit": "Dangos",
+ "logeventslist-patrol-log": "Log patrolio",
+ "logeventslist-tag-log": "Log y tagiau",
"all-logs-page": "Pob lòg cyhoeddus",
"alllogstext": "Mae pob cofnod yn holl logiau {{SITENAME}} wedi cael eu rhestru yma.\nGallwch weld chwiliad mwy penodol trwy ddewis y math o lòg, enw'r defnyddiwr, neu'r dudalen benodedig.\nSylwer bod llythrennau mawr neu fach o bwys i'r chwiliad.",
"logempty": "Does dim eitemau yn cyfateb yn y lòg.",
"delete-confirm": "Dileu \"$1\"",
"delete-legend": "Dileu",
"historywarning": "<strong>Rhybudd:</strong> bu tua $1 {{PLURAL:$1|golygiad|golygiad|olygiad|golygiad|golygiad|o olygiadau}} yn hanes y dudalen rydych ar fin ei dileu:",
- "historyaction-submit": "Dangos",
+ "historyaction-submit": "Dangos yr adolygiadau",
"confirmdeletetext": "Rydych chi ar fin dileu tudalen neu ddelwedd, ynghŷd â'i hanes, o'r data-bas, a hynny'n barhaol.\nOs gwelwch yn dda, cadarnhewch eich bod chi wir yn bwriadu gwneud hyn, eich bod yn deall y canlyniadau, ac yn ei wneud yn ôl [[{{MediaWiki:Policy-url}}|polisïau {{SITENAME}}]].",
"actioncomplete": "Wedi cwblhau'r weithred",
"actionfailed": "Methodd y weithred",
"dellogpage": "Lòg dileuon",
"dellogpagetext": "Ceir rhestr isod o'r dileadau diweddaraf.",
"deletionlog": "lòg dileuon",
+ "log-name-create": "Log creu tudalennau",
+ "log-description-create": "Nodir isod y rhestr o'r tudalennau newydd mwyaf diweddar.",
+ "logentry-create-create": "$1 {{GENDER:$2|created}} tudalen $3",
"reverted": "Wedi gwrthdroi i'r golygiad cynt",
"deletecomment": "Rheswm:",
"deleteotherreason": "Rheswm arall:",
"deleting-backlinks-warning": "'''Rhybudd:''' Mae [[Special:WhatLinksHere/{{FULLPAGENAME}}|tudalennau eraill]] yn cysylltu i'r ddalen rydych ar fin ei dileu.",
"deleting-subpages-warning": "<strong>Rhybudd:</strong> Mae gan y ddalen rydych ar fin ei dileu [[Special:PrefixIndex/{{FULLPAGENAME}}/|{{PLURAL:$1|is-ddalen|$1 is-ddalennau|51=dros 50 o is-ddalennau}}]].",
"rollback": "Gwrthdroi golygiadau",
+ "rollback-confirmation-confirm": "Cadarnhewch:",
+ "rollback-confirmation-yes": "Troi'n ol",
+ "rollback-confirmation-no": "Canslo",
"rollbacklink": "gwrthdröer",
"rollbacklinkcount": "gwrthdröer $1 {{PLURAL:$1||golygiad|olygiad|golygiad}}",
"rollbacklinkcount-morethan": "gwrthdröer mwy na $1 {{PLURAL:$1||golygiad|olygiad|golygiad}}",
"revertpage-nouser": "Wedi gwrthdroi golygiadau gan ddefnyddiwr cudd; wedi adfer y golygiad diweddaraf gan {{GENDER:$1|[[User:$1|$1]]}}",
"rollback-success": "Gwrthdrowyd y golygiadau gan {{GENDER:$3|$1}};\nailosodwyd y golygiad olaf gan {{GENDER:$4|$2}}.",
"sessionfailure-title": "Sesiwn wedi methu",
- "sessionfailure": "Mae'n debyg fod yna broblem gyda'ch sesiwn mewngofnodi; diddymwyd y weithred er mwyn diogelu'r sustem rhag ddefnyddwyr maleisus. Gwasgwch botwm 'nôl' eich porwr ac ail-lwythwch y dudalen honno, yna ceisiwch eto.",
+ "sessionfailure": "Mae'n debyg fod yna broblem gyda'ch sesiwn mewngofnodi;\ndiddymwyd y weithred er mwyn diogelu'r sustem rhag ddefnyddwyr maleisus.\nAilgyflwynwch y ffurflen.",
"changecontentmodel-title-label": "Teitl y ddalen",
"changecontentmodel-reason-label": "Rheswm:",
"changecontentmodel-submit": "Newid",
"undelete-search-title": "Chwilio drwy'r tudalennau dilëedig",
"undelete-search-box": "Chwilio tudalennau a ddilëwyd",
"undelete-search-prefix": "Dangos tudalennau gan ddechrau gyda:",
+ "undelete-search-full": "Dangos tudalennau sy'n cynnwys:",
"undelete-search-submit": "Chwilio",
"undelete-no-results": "Ni chafwyd hyd i dudalennau cyfatebol yn archif y dileuon.",
"undelete-filename-mismatch": "Nid oes modd dad-ddileu'r golygiad ffeil â'r stamp amser $1: nid oedd enw'r ffeil yn cydweddu",
"ipb-disableusertalk": "Atal y defnyddiwr hwn rhag golygu ei dudalen/ei thudalen sgwrs ei hunan wrth i'r bloc fod yn weithredol",
"ipb-change-block": "Ailflocio'r defnyddiwr hwn gyda'r gosodiadau hyn",
"ipb-confirm": "Cadarnhau'r rhwystr",
+ "ipb-pages-label": "Tudalennau",
+ "ipb-namespaces-label": "Parthenwau",
"badipaddress": "Cyfeiriad IP annilys.",
"blockipsuccesssub": "Llwyddodd y rhwystr",
"blockipsuccesstext": "Mae [[Special:Contributions/$1|$1]] wedi cael ei flocio.<br />\nGweler y [[Special:BlockList|rhestr blociau]] er mwyn arolygu blociau.",
"ipb-blocklist": "Dangos y blociau cyfredol",
"ipb-blocklist-contribs": "Cyfraniadau {{GENDER:$1|$1}}",
"block-expiry": "Am gyfnod:",
+ "block-prevent-edit": "Golygu",
+ "block-reason": "Rhesymau:",
+ "block-target": "Cyfeiriad IP y Defnyddiwr",
"unblockip": "Dadflocio defnyddiwr",
"unblockiptext": "Defnyddiwch y ffurflen isod i ail-alluogi golygiadau gan ddefnyddiwr neu o gyfeiriad IP a fu gynt wedi'i flocio.",
"ipusubmit": "Tynnu'r rhwystr hwn",
"unblocked-id": "Tynnwyd rhwystr $1",
"unblocked-ip": "Mae [[Special:Contributions/$1|$1]] wedi ei atal.",
"blocklist": "Defnyddwyr a rwystrwyd",
+ "autoblocklist": "Rhwystrau otomatig",
"autoblocklist-submit": "Chwilio",
+ "autoblocklist-legend": "Rhestr o rwystrau otomatig",
+ "autoblocklist-localblocks": "{{PLURAL:$1|autoblock|autoblocks}} lleol",
+ "autoblocklist-total-autoblocks": "Cyfanswm y rhwystrau otomatig: $1",
"ipblocklist": "Defnyddwyr a rwystrwyd",
"ipblocklist-legend": "Dod o hyd i ddefnyddiwr a rwystrwyd",
"blocklist-userblocks": "Cuddio rhwystrau cyfrifon",
"emailblock": "rhwystrwyd e-bostio",
"blocklist-nousertalk": "ni all olygu ei dudalen/ei thudalen sgwrs ei hunan",
"ipblocklist-empty": "Mae'r rhestr rwystrau'n wag.",
- "ipblocklist-no-results": "Nid yw cyfeiriad IP neu enw defnyddiwr yr ymholiad wedi'i rwystro.",
+ "ipblocklist-no-results": "Ni chafwyd hyn i rwystrau o nanut yma ar gyfer cyfeiriad IP neu enw defnyddiwr.",
"blocklink": "rhwystro",
"unblocklink": "dadrwystro",
"change-blocklink": "newid y rhwystr",
"delete_and_move_text": "==Angen dileu==\n\nMae'r erthygl \"[[:$1]]\" yn bodoli'n barod. Ydych chi am ddileu'r erthygl er mwyn paratoi lle?",
"delete_and_move_confirm": "Ie, dileu'r dudalen",
"delete_and_move_reason": "Wedi'i dileu er mwyn gallu symud y dudalen \"[[$1]]\" i gymryd ei lle",
- "selfmove": "Mae'r teitlau hen a newydd yn union yr un peth;\nnid yw'n bosib cyflawnu'r symud.",
+ "selfmove": "Mae'r teitlau yr un peth;\nnid yw'n symud tudalen iddi hi ei hun.",
"immobile-source-namespace": "Ni ellir symud tudalennau yn y parth \"$1\".",
"immobile-target-namespace": "Ni ellir symud tudalennau i'r parth \"$1\".",
"immobile-target-namespace-iw": "Nid yw cyswllt rhyngwici yn nod dilys wrth symud tudalen.",
"fix-double-redirects": "Yn diwygio unrhyw ailgyfeiriadau sy'n cysylltu i'r teitl gwreiddiol",
"move-leave-redirect": "Creu tudalen ail-gyfeirio â'r teitl gwreiddiol",
"protectedpagemovewarning": "'''Sylwer:''' Clowyd y dudalen ac felly dim ond defnyddwyr a galluoedd gweinyddu ganddynt sy'n gallu ei symud.\nDyma'r cofnod lòg diweddaraf, er gwybodaeth:",
- "semiprotectedpagemovewarning": "'''Sylwer:''' Clowyd y dudalen ac felly dim ond defnyddwyr mewngofnodedig sy'n gallu ei symud.\nDyma'r cofnod lòg diweddaraf, er gwybodaeth:",
+ "semiprotectedpagemovewarning": "<strong>Sylwer:</strong> Clowyd y dudalen. Dim ond defnyddwyr mewngofnodedig sy'n gallu ei symud.\nDyma'r cofnod lòg diweddaraf isod, er gwybodaeth:",
"move-over-sharedrepo": "Mae'r ffeil [[:$1]] ar gael mewn storfa gyfrannol. Pe byddech yn symud y ffeil i'r teitl hwn, yna byddai'r ffeil o'r storfa gyfrannol yn cael ei disodli.",
"file-exists-sharedrepo": "Mae'r enw y dewisoch ar y ffeil yn cael ei ddefnyddio'n barod ar storfa gyfrannol.\nDewiswch enw arall os gwelwch yn dda.",
"export": "Allforio tudalennau",
"previousdiff": "← Y fersiwn gynt",
"nextdiff": "Y fersiwn dilynol →",
"mediawarning": "'''Rhybudd''': Gallasai'r math hwn o ffeil gynnwys côd maleisus.\nMae'n bosib y bydd eich cyfrifiadur yn cael ei danseilio wrth ddefnyddio'r ffeil.",
- "imagemaxsize": "Maint mwyaf y delweddau:<br />''(ar y tudalennau disgrifiad)''",
+ "imagemaxsize": "Ceir cyfyngiad ar faint tudalennau disgrifio'r ffeil:",
"thumbsize": "Maint mân-lun :",
"widthheightpage": "$1 × $2, $3 {{PLURAL:$3|tudalen|dudalen|dudalen|tudalen|thudalen|tudalen}}",
"file-info": "maint y ffeil: $1, ffurf MIME: $2",
"confirm-unwatch-top": "Tynner y dudalen hon oddi ar eich rhestr wylio?",
"confirm-rollback-button": "Iawn",
"confirm-rollback-top": "Dadwneud golygiadau'r ddalen hon?",
+ "confirm-mcrrestore-title": "Adfer diwygiadau",
+ "confirm-mcrundo-title": "Dadwneud y newidiadau",
+ "mcrundofailed": "Methwyd gwrthdroi",
"quotation-marks": "'$1'",
"imgmultipageprev": "← i'r dudalen gynt",
"imgmultipagenext": "i'r dudalen nesaf →",
"version-poweredby-others": "eraill",
"version-poweredby-translators": "cyfieithwyr translatewiki.net",
"version-credits-summary": "Hoffem gydnabod cyfraniad y bobl canlynol i [[Special:Version|MediaWiki]].",
- "version-license-info": "Meddalwedd rhydd yw MediaWiki; gallwch ei ddefnyddio a'i addasu yn ôl termau'r GNU General Public License a gyhoeddir gan Free Software Foundation; naill ai fersiwn 2 o'r Drwydded, neu unrhyw fersiwn diweddarach o'ch dewis.\n\nCyhoeddir MediaWiki yn y gobaith y bydd o ddefnydd, ond HEB UNRHYW WARANT; heb hyd yn oed gwarant ymhlyg o FARCHNADWYEDD nag o FOD YN ADDAS AT RYW BWRPAS ARBENNIG. Gweler y GNU General Public License am fanylion pellach.\n\nDylech fod wedi derbyn [{{SERVER}}{{SCRIPTPATH}}/COPYING gopi o GNU General Public License] gyda'r rhaglen hon; os nad ydych, ysgrifennwch at Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, neu [//www.gnu.org/licenses/old-licenses/gpl-2.0.html gallwch ei ddarllen ar y we].",
+ "version-license-info": "Meddalwedd rhydd ac agored yw MediaWiki; gallwch ei ailddosbarthu a'i addasu yn ôl termau'r GNU General Public License a gyhoeddir gan Free Software Foundation; naill ai fersiwn 2 o'r Drwydded, neu unrhyw fersiwn diweddarach o'ch dewis.\n\nCyhoeddir MediaWiki yn y gobaith y bydd o ddefnydd, ond <em>HEB UNRHYW WARANT</em>; heb hyd yn oed gwarant ymhlyg o <strong>FARCHNADWYEDD</strong> nag o <strong>FOD YN ADDAS AT RYW BWRPAS ARBENNIG</strong>. Gweler y GNU General Public License am fanylion pellach.\n\nDylech fod wedi derbyn [{{SERVER}}{{SCRIPTPATH}}/COPYING copi o GNU General Public License] gyda'r rhaglen hon; os nad ydych, ysgrifennwch at Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, neu [//www.gnu.org/licenses/old-licenses/gpl-2.0.html gallwch ei ddarllen ar y we].",
"version-software": "Meddalwedd gosodedig",
"version-software-product": "Cynnyrch",
"version-software-version": "Fersiwn",
"redirect-file": "Enwau ffeiliau",
"redirect-logid": "Log yr ID",
"redirect-not-exists": "Heb lwyddo i'w ganfod",
+ "redirect-not-numeric": "Nid yw'r gwerth yn rhif",
"fileduplicatesearch": "Chwilio am ffeiliau dyblyg",
"fileduplicatesearch-summary": "Chwilier am ffeiliau dyblyg ar sail ei werth stwnsh.",
"fileduplicatesearch-filename": "Enw'r ffeil:",
"specialpages-group-developer": "Arfau ar gyfer y Datblygwr",
"blankpage": "Tudalen wag",
"intentionallyblankpage": "Gadawyd y dudalen hon yn wag o fwriad",
+ "disabledspecialpage-disabled": "Anallugwyd y dudalen gan weinyddwr y system.",
"external_image_whitelist": " #Leave this line exactly as it is<pre>\n#Gosodwch ddarnau o ymadroddion rheolaidd (y rhan sy'n cael ei osod rhwng y //) isod\n#Caiff y rhain eu cysefeillio gyda URL y delweddau allanol (a chyswllt poeth atynt)\n#Dangosir y rhai sy'n cysefeillio fel delweddau; dangosir cyswllt at y ddelwedd yn unig ar gyfer y lleill\n#Caiff y llinellau sy'n dechrau gyda # eu trin fel sylwadau\n#Nid yw'n gwahaniaethu rhwng llythrennau mawr a bach\n\n#Put all regex fragments above this line. Leave this line exactly as it is</pre>",
"tags": "Tagiau newidiadau",
"tag-filter": "Hidl [[Special:Tags|tagiau]]:",
"tag-filter-submit": "Hidlo",
"tag-list-wrapper": "[[Special:Tags|{{PLURAL:$1|Tag|Tagiau}}]]: $2",
+ "tag-mw-undo": "Dadwneud",
"tags-title": "Tagiau",
"tags-intro": "Dyma restr o'r tagiau y mae'r meddalwedd yn defnyddio i farcio golygiad, ynghyd â'r rhesymau dros eu defnyddio.",
"tags-tag": "Enw'r tag",
"logentry-rights-autopromote": "{{GENDER:$2|Dyrchafwyd}} $1 yn awtomatig o $4 i $5",
"logentry-upload-upload": "Mae $1 {{GENDER:$2|wedi uwchlwytho}} $3",
"logentry-upload-overwrite": "Mae $1 {{GENDER:$2|wedi uwchlwytho}} fersiwn newydd o $3",
- "logentry-upload-revert": "Mae $1 {{GENDER:$2|wedi uwchlwytho}} $3",
+ "logentry-upload-revert": "Mae $1 {{GENDER:$2|wedi gwrthdroi}} $3 i fersiwn hyn",
"rightsnone": "(dim)",
"feedback-adding": "Wrthi'n ychwanegu adborth i'r dudalen...",
"feedback-bugcheck": "Iawn! Gwnewch yn siwr yn gyntaf nag ydy hwn yn un o'r [$1 bygiau hysbys].",
"api-error-emptypage": "Ni chaniateir dechrau tudalen newydd, a honno'n wag.",
"api-error-publishfailed": "Gwall mewnol: methodd y gweinydd â chyhoeddi'r ffeil dros dro.",
"api-error-stashfailed": "Gwall mewnol: methodd y gweinydd â rhoi'r ffeil dros dro ar gadw.",
- "api-error-unknown-warning": "Rhybudd anhysbys: $1",
+ "api-error-unknown-warning": "Rhybudd anhysbys: \"$1\".",
"api-error-unknownerror": "Gwall anhysbys: \"$1\".",
"duration-seconds": "$1 {{PLURAL:$1|eiliad}}",
"duration-minutes": "$1 {{PLURAL:$1|munud|munud|funud|munud|munud|munud}}",
"limitreport-expensivefunctioncount": "Nifer y ffwythiannau dosrannu sy'n dreth ar adnoddau",
"expandtemplates": "Ehangu'r nodynnau",
"expand_templates_title": "Teitl y cyd-destun, ar gyfer {{FULLPAGENAME}}, etc.:",
- "expand_templates_input": "Cynnwys y mewnbwn:",
+ "expand_templates_input": "Codwici'r mewnbwn:",
"expand_templates_output": "Y canlyniad",
"expand_templates_xml_output": "Yr allbwn XML",
"expand_templates_html_output": "Allbwn HTML crai",
"november": "Tışrino Peyên",
"december": "Kanun",
"january-gen": "Çele",
- "february-gen": "Gucige",
- "march-gen": "Adar",
+ "february-gen": "Şıbat",
+ "march-gen": "Mert",
"april-gen": "Nisane",
"may-gen": "Gulane",
"june-gen": "Heziran",
"november-gen": "Tışrino Peyên",
"december-gen": "Kanun",
"jan": "Çel",
- "feb": "Gcg",
+ "feb": "Şbt",
"mar": "Adr",
"apr": "Nsn",
"may": "Gul",
"nov": "Tşp",
"dec": "Gğn",
"january-date": "$1 Çele",
- "february-date": "$1 Gucige",
+ "february-date": "$1 Şıbat",
"march-date": "$1 Adar",
"april-date": "$1 Nisane",
"may-date": "$1 Gulane",
"nstab-template": "Şablon",
"nstab-help": "Perra pasti",
"nstab-category": "Kategoriye",
- "mainpage-nstab": "Pela seri",
+ "mainpage-nstab": "Pera seri",
"nosuchaction": "Fealiyeto wınasi çıniyo",
"nosuchactiontext": "URL ra kar qebul nêbı.\nŞıma belka URL şaş nuşt, ya zi gıreyi şaş ra ameyi.\nKeyepelê {{SITENAME}} eşkeno xeta aşkera bıkero.",
"nosuchspecialpage": "Pela hısusiya wınasiyên çıniya.",
"history": "Page history",
"history_short": "History",
"history_small": "history",
- "updatedmarker": "updated since my last visit",
+ "updatedmarker": "updated since your last visit",
"printableversion": "Printable version",
"permalink": "Permanent link",
"print": "Print",
"specialmute-error-invalid-user": "The username requested could not be found.",
"specialmute-error-email-blacklist-disabled": "Muting users from sending you emails is not enabled.",
"specialmute-error-email-preferences": "You must confirm your email address before you can mute a user. You may do so from [[Special:Preferences]].",
- "specialmute-email-footer": "[$1 Manage email preferences for {{BIDI:$2}}.]",
+ "specialmute-email-footer": "To manage email preferences for {{BIDI:$2}} please visit <$1>.",
"specialmute-login-required": "Please log in to change your mute preferences.",
"revid": "revision $1",
"pageid": "page ID $1",
"history": "Paĝa historio",
"history_short": "Historio",
"history_small": "historio",
- "updatedmarker": "ĝisdatigita de post mia lasta vizito",
+ "updatedmarker": "ĝisdatigita de post via lasta vizito",
"printableversion": "Presebla versio",
"permalink": "Konstanta ligilo",
"print": "Presi",
"edit-error-short": "Eraro: $1",
"edit-error-long": "Eraroj:\n\n$1",
"specialmute": "Silentigi",
+ "specialmute-success": "Sukcese ĝisdatiĝis viaj preferoj pri kaŝado de mesaĝoj. Vi povas vidi ĉiujn silentigitajn uzantojn ĉe [[Special:Preferences]].",
"specialmute-submit": "Konfirmi",
"specialmute-label-mute-email": "Kaŝi retmesaĝojn el ĉi tiu uzanto",
+ "specialmute-header": "Bonvolu elekti viajn preferojn pri kaŝado de mesaĝoj el {{BIDI:[[User:$1]]}}.",
"specialmute-error-invalid-user": "La petita uzantnomo ne troviĝis.",
+ "specialmute-error-email-blacklist-disabled": "Malŝaltiĝis kaŝado de retmesaĝoj el specifaj uzantoj.",
+ "specialmute-error-email-preferences": "Vi povas konfirmi vian retpoŝtan adreson, antaŭ vi povas kaŝi mesaĝojn. Vi povas tion fari ĉe [[Special:Preferences]].",
"specialmute-email-footer": "[$1 Administri preferojn pri retpoŝto por {{BIDI:$2}}.]",
+ "specialmute-login-required": "Bonvolu ensaluti por konservi vian preferon pri kaŝado de mesaĝoj.",
"revid": "revizio $1",
"pageid": "Identigilo de paĝo $1",
"interfaceadmin-info": "$1\n\nPermesoj pri redaktado de tut-retejaj CSS/JavaScript/JSON-dosieroj estis lastatempe disigitaj for de la rajto <code>editinterface</code>. Se vi ne komprenas kial vi ricevis ĉi tiun eraron, vidu la paĝon [[mw:MediaWiki_1.32/interface-admin]].",
"restrictionsfield-help": "Une adresse IP ou une plage CIDR par ligne. Pour tout activer, utiliser :<pre>0.0.0.0/0\n::/0</pre>",
"edit-error-short": "Erreur : $1",
"edit-error-long": "Erreurs :\n\n$1",
+ "specialmute": "Muet",
+ "specialmute-submit": "Confirmer",
+ "specialmute-error-invalid-user": "Le nom d'utilisateur demandé n'a pu être trouvé.",
"revid": "version $1",
"pageid": "ID de page $1",
"interfaceadmin-info": "$1\n\nLes droits pour modifier les fichiers CSS/JS/JSON globaux au site ont été récemment séparés du droit <code>editinterface</code>. Si vous ne comprenez pas pourquoi vous avez cette erreur, voyez [[mw:MediaWiki_1.32/interface-admin]].",
"history": "Historial da páxina",
"history_short": "Historial",
"history_small": "historial",
- "updatedmarker": "actualizado desde a miña última visita",
+ "updatedmarker": "actualizado desde a súa última visita",
"printableversion": "Versión para imprimir",
"permalink": "Ligazón permanente",
"print": "Imprimir",
"Vaishali Parab",
"The Discoverer",
"Cliffa fernandes",
- "Rxy"
+ "Rxy",
+ "Isidore Dantas"
]
},
"tog-hideminor": "हालींच बदल केल्ल्यांतले बारीक संपादन लिपय",
"nstab-template": "सांचो",
"nstab-help": "आदाराचें पान",
"nstab-category": "वर्ग",
+ "mainpage-nstab": "मुखेल पान",
"nosuchaction": "असले तरेचे कार्य ना",
"nosuchspecialpage": "असले कांयच विशेश पान ना",
"error": "चूक",
"histfirst": "ամենահին",
"histlast": "ամենաթարմ",
"historysize": "({{PLURAL:$1|1 բայթ|$1 բայթ}})",
- "historyempty": "(դատարկ)",
+ "historyempty": "դատարկ",
"history-feed-title": "Փոփոխությունների պատմություն",
"history-feed-description": "Վիքիի այս էջի փոփոխումների պատմություն",
"history-feed-item-nocomment": "$1՝ $2",
"revdelete-unsuppress": "Հանել սահմանափակումները վերականգնված տարբերակներից",
"revdelete-log": "Պատճառ.",
"revdelete-submit": "Կիրառել ընտրված {{PLURAL:$1|տարբերակի|տարբերակների}} վրա",
- "revdelete-success": "'''Տարբերակի տեսանելիությունը բարեհաջող թարմացված է։'''",
+ "revdelete-success": "Տարբերակի տեսանելիությունը թարմացված է։",
"revdelete-failure": "Խմբագրման տեսանելիություն հնարավոր չէր փոփոխել՝\n$1",
- "logdelete-success": "'''Իրադարձության տեսանելիությունը փոփոխված է։'''",
+ "logdelete-success": "Իրադարձության տեսանելիությունը փոփոխված է։",
"revdel-restore": "Փոխել տեսանելիությունը",
"pagehist": "Էջի պատմություն",
"deletedhist": "Ջնջումների պատմություն",
"contribsub2": "{{GENDER:$3|$1}}-ի ներդրումները ($2)",
"contributions-subtitle": "{{GENDER:$3|$1}}-ի համար",
"nocontribs": "Այս չափանիշներին համապատասխանող փոփոխություններ չեն գտնվել։",
- "uctop": " վերջինը",
+ "uctop": "վերջինը",
"month": "Սկսած ամսից (և վաղ)՝",
"year": "Սկսած տարեթվից (և վաղ)՝",
"sp-contributions-newbies": "Ցույց տալ միայն նորաստեղծ հաշիվներից կատարված ներդրումները",
"restrictionsfield-help": "Un adresse IP o intervallo CIDR per linea. Pro activar toto, usa:<pre>0.0.0.0/0\n::/0</pre>",
"edit-error-short": "Error: $1",
"edit-error-long": "Errores:\n\n$1",
+ "specialmute": "Silentio",
+ "specialmute-success": "Tu preferentias de silentio ha essite actualisate. Vide tote le usatores silentiate in [[Special:Preferences]].",
+ "specialmute-submit": "Confirmar",
+ "specialmute-label-mute-email": "Silentiar e-mail de iste usator",
+ "specialmute-header": "Selige tu preferentias de silentio pro {{BIDI:[[User:$1]]}}.",
+ "specialmute-error-invalid-user": "Le nomine de usator que tu requestava non pote esser trovate.",
+ "specialmute-error-email-blacklist-disabled": "Le silentiamento de usatores pro inviar te e-mail non ha essite activate.",
+ "specialmute-error-email-preferences": "Tu debe confirmar tu adresse de e-mail ante de poter silentiar un usator. Face isto in [[Special:Preferences]].",
+ "specialmute-email-footer": "[$1 Gerer preferentias de e-mail pro {{BIDI:$2}}.]",
+ "specialmute-login-required": "Es necessari aperir session pro cambiar le preferentias de silentio.",
"revid": "version $1",
"pageid": "ID de pagina $1",
"interfaceadmin-info": "$1\n\nLe permissiones pro modificar le files CSS/JS/JSON global del sito ha recentemente essite separate del privilegio <code>editinterface</code>. Si tu non comprende proque tu recipe iste error, vide [[mw:MediaWiki_1.32/interface-admin]].",
"editingcomment": "Mbesut $1 (pérangan anyar)",
"editconflict": "Cengkah besutan: $1",
"explainconflict": "Wong liya wis mbesut kaca iki wiwit panjenengan lekas mbesut.\nBagian dhuwur tèks iki ngamot tèks kaca vèrsi saiki.\nPangowahan kang panjenengan lakoni dituduhaké ing bagian ngisor tèks.\nPanjenengan namung prelu nggabungaké pangowahan panjenengan karo tèks kang wis ana.\n'''Namung''' tèks ing bagian dhuwur kaca kang bakal kasimpen manawa panjenengan mencèt \"$1\".",
- "yourtext": "Tèksé panjenengan",
+ "yourtext": "Tèksmu",
"storedversion": "Owahan kasimpen",
"editingold": "'''PÈNGET:''' Panjenengan mbesut revisi lawas saka siji kaca. Yèn versi iki panjenengan simpen, mengko pangowahan-pangowahan kang wis digawé wiwit revisi iki bakal ilang.",
"yourdiff": "Béda",
"pageinfo-authors": "Skirtingų autorių skaičius",
"pageinfo-recent-edits": "Paskutinųjų keitimų skaičius (per $1 laikotarpį)",
"pageinfo-recent-authors": "Pastarųjų skirtingų redaguotojų skaičius",
- "pageinfo-magic-words": "Magiškas(-i) {{PLURAL:$1|žodis|žodžiai}} ($1)",
+ "pageinfo-magic-words": "Magiški {{PLURAL:$1|žodis|žodžiai}} ($1)",
"pageinfo-hidden-categories": "{{PLURAL:$1|Paslėpta kategorija|Paslėptos kategorijos|Paslėptų kategorijų}} ($1)",
"pageinfo-templates": "{{PLURAL:$1|Įtrauktas šablonas|Įtraukti šablonai|Įtrauktų šablonų}} ($1)",
"pageinfo-transclusions": "{{PLURAL:$1|Įtrauktas puslapis|Įtraukti puslapiai|Įtrauktų puslapių}} ($1)",
"explainconflict": "သင် စတင်တည်းဖြတ်ကတည်းက တစ်စုံတစ်ယောက်မှ ဤစာမျက်နှာကို ပြောင်းလဲခဲ့သည်။ အပေါ်ပိုင်းဧရိယာတွင် လက်ရှိတည်ရှိနေသော စာမျက်နှာစာသား ပါဝင်သည်။ သင်၏ပြောင်းလဲချက်များကို အောက်ပိုင်းစာသားဧရိယာတွင် ပြသပေးထားသည်။ သင်၏ပြောင်းလဲချက်များကို ရှိနှင့်ပြီးသားစာသားတွင် ပေါင်းစပ်ရမည်ဖြစ်ပါသည်။ \"$1\" ကို သင်နှိပ်လိုက်ပါက အပေါ်ပိုင်းဧရိယာရှိ စာသား<strong>သာလျင်</strong> သိမ်းဆည်းသွားမည်ဖြစ်ပါသည်။",
"yourtext": "သင့်စာသား",
"storedversion": "သိမ်းဆည်းထားသောမူ",
+ "editingold": "<strong>သတိပေးချက်: သင်သည် ဤစာမျက်နှာ၏ ခေတ်နောက်ကျသောမူကို တည်းဖြတ်နေခြင်းဖြစ်သည်။</strong>\nသိမ်းဆည်းလိုက်ပါက ယခင်မူဟောင်းမှ မည်သည့်ပြောင်းလဲချက်များမဆို ပျောက်ဆုံးသွားမည်ဖြစ်သည်။",
"yourdiff": "ကွဲပြားချက်များ",
"copyrightwarning": "{{SITENAME}} တွင် ရေးသားမှုအားလုံးကို $2 အောက်တွင် ဖြန့်ဝေရန် ဆုံးဖြတ်ပြီး ဖြစ်သည်ကို ကျေးဇူးပြု၍ သတိပြုပါ။။ (အသေးစိတ်ကို $1 တွင်ကြည့်ပါ။)\nအကယ်၍ သင့်ရေးသားချက်များကို အညှာအတာမရှိ တည်းဖြတ်ခံရခြင်း၊ စိတ်တိုင်းကျ ဖြန့်ဝေခံရခြင်းတို့ကို အလိုမရှိပါက ဤနေရာတွင် မတင်ပါနှင့်။<br />\nသင်သည် ဤဆောင်းပါးကို သင်ကိုယ်တိုင်ရေးသားခြင်း၊ သို့မဟုတ် အများပြည်သူဆိုင်ရာဒိုမိန်းများ၊ ယင်းကဲ့သို့ လွတ်လပ်သည့် ရင်းမြစ်မှ ကူးယူထားခြင်း ဖြစ်ကြောင်းလည်း ဝန်ခံ ကတိပြုပါသည်။\n<strong>မူပိုင်ခွင့်ရှိသော စာ၊ပုံများကို ခွင့်ပြုချက်မရှိဘဲ မတင်ပါနှင့်။</strong>",
"copyrightwarning2": "{{SITENAME}} တွင် ရေးသားမှုအားလုံးသည် အခြားပုံပိုးသူများ၏ တည်းဖြတ်၊ ပြောင်းလဲ၊ ဖယ်ရှားခံရနိုင်သည်ကို သတိပြုပါ။\nအကယ်၍ သင့်ရေးသားချက်များကို အညှာအတာမရှိ တည်းဖြတ်ခံရခြင်း၊ စိတ်တိုင်းကျ ဖြန့်ဝေခံရခြင်းတို့ကို အလိုမရှိပါက ဤနေရာတွင် မတင်ပါနှင့်။<br />\nသင်သည် ဤဆောင်းပါးကို သင်ကိုယ်တိုင်ရေးသားခြင်း၊ သို့မဟုတ် အများပြည်သူဆိုင်ရာဒိုမိန်းများ၊ ယင်းကဲ့သို့ လွတ်လပ်သည့် ရင်းမြစ်မှ ကူးယူထားခြင်း ဖြစ်ကြောင်းလည်း ဝန်ခံ ကတိပြုပါသည် (အသေးစိတ်ကို $1 တွင်ကြည့်ပါ)။\n<strong>မူပိုင်ခွင့်ရှိသော စာ၊ပုံများကို ခွင့်ပြုချက်မရှိဘဲ မတင်ပါနှင့်။</strong>",
"Liuxinyu970226",
"Yoxem",
"Matěj Suchánek",
- "Reke"
+ "Reke",
+ "LNDDYL"
]
},
"tog-underline": "Liân-kiat oē té-sûn:",
"tog-editsectiononrightclick": "Chiàⁿ ji̍h toāⁿ-lo̍h phiau-tê to̍h ē-tàng pian-chi̍p toāⁿ-lo̍h",
"tog-watchcreations": "Kā goá khui ê ia̍h kah chiūⁿ-chái ê tóng-àn ka-ji̍p kàm-sī-toaⁿ lāi-té",
"tog-watchdefault": "Kā goá pian-chi̍p kòe ê ia̍h kah tóng-àn ka-ji̍p kàm-sī-toaⁿ lāi-té",
- "tog-watchmoves": "Kā goá soá ê ia̍h kah tóng-àn ka-ji̍p kàm-sī-toaⁿ",
+ "tog-watchmoves": "Kā góa sóa ê ia̍h kah tóng-àn ka-ji̍p kàm-sī-toaⁿ",
"tog-watchdeletion": "Kā goá thâi tiāu ê ia̍h kah tóng-àn ka-ji̍p kàm-sī-toaⁿ",
"tog-watchuploads": "Chiong góa ap-ló͘ ê tóng-àn ka-ji̍p kam-sī-toaⁿ",
"tog-watchrollback": "Chiong góa í-keng ká--tńg-khì ê ia̍h-bīn ka-ji̍p góa-ê kam-sī-toaⁿ",
"block-log-flags-anononly": "Kaⁿ-taⁿ bô-miâ iōng-chiá",
"block-log-flags-nocreate": "Khui kháu-chō thêng-iōng ah",
"locknoconfirm": "Lí bô kau \"khak-tēng\" ê keh-á.",
- "move-page": "徙$1",
+ "move-page": "Sóa $1",
"move-page-legend": "Sóa ia̍h",
"movepagetext": "Ē-kha chit ê form> iōng lâi kái 1 ê ia̍h ê piau-tê (miâ-chheng); só·-ū siong-koan ê le̍k-sú ē tòe leh sóa khì sin piau-tê.\nKū piau-tê ē chiâⁿ-chò 1 ia̍h choán khì sin piau-tê ê choán-ia̍h.\nLiân khì kū piau-tê ê liân-kiat (link) bē khì tāng--tio̍h; ē-kì-tit chhiau-chhōe siang-thâu (double) ê a̍h-sī kò·-chiòng ê choán-ia̍h.\nLí ū chek-jīm khak-tēng liân-kiat kè-sio̍k liân tio̍h ūi.\n\nSin piau-tê nā í-keng tī leh (bô phian-chi̍p koè ê khang ia̍h, choán-ia̍h bô chún-sǹg), tō bô-hoat-tō· soá khì hia.\nChe piaú-sī nā ū têng-tâⁿ, ē-sái kā sin ia̍h soà tńg-khì goân-lâi ê kū ia̍h.\n\n'''SÈ-JĪ!'''\nTùi chē lâng tha̍k ê ia̍h lâi kóng, soá-ūi sī toā tiâu tāi-chì.\nLiâu--lo̍h-khì chìn-chêng, chhiáⁿ seng khak-tēng lí ū liáu-kái chiah-ê hiō-kó.",
- "movepagetalktext": "Siong-koan ê thó-lūn-ia̍h (chún ū) oân-nâ ē chū-tōng tòe leh sóa-ūi. Í-hā ê chêng-hêng '''bô chún-sǹg''': *Beh kā chit ia̍h tùi 1 ê miâ-khong-kan (namespace) soá khì lēng-gōa 1 ê miâ-khong-kan, *Sin piau-tê í-keng ū iōng--kòe ê thó-lūn-ia̍h, he̍k-chiá *Ē-kha ê sió-keh-á bô phah-kau. Í-siōng ê chêng-hêng nā-chún tī leh, lí chí-hó iōng jîn-kang ê hong-sek sóa ia̍h a̍h-sī kā ha̍p-pèng (nā ū su-iàu).",
+ "movepagetalktext": "Siong-koan ê thó-lūn-ia̍h (chún ū) oân-nâ ē chū-tōng tòe leh sóa-ūi. Í-hā ê chêng-hêng '''bô chún-sǹg''': *Beh kā chit ia̍h tùi 1 ê miâ-khong-kan (namespace) sóa khì lēng-gōa 1 ê miâ-khong-kan, *Sin piau-tê í-keng ū iōng--kòe ê thó-lūn-ia̍h, he̍k-chiá *Ē-kha ê sió-keh-á bô phah-kau. Í-siōng ê chêng-hêng nā-chún tī leh, lí chí-hó iōng jîn-kang ê hong-sek sóa ia̍h a̍h-sī kā ha̍p-pèng (nā ū su-iàu).",
"movenologintext": "Lí it-tēng ài sī chù-chheh ê iōng-chiá jī-chhiáⁿ ū [[Special:UserLogin|teng-ji̍p]] chiah ē-tàng sóa ia̍h.",
"newtitle": "Khì sin piau-tê:",
"move-watch": "Kàm-sī chit ia̍h",
"movetalk": "Sūn-sòa sóa thó-lūn-ia̍h",
"movepage-page-moved": "$1 í-keng sóa khì tī $2.",
"movepage-page-unmoved": "$1 chit ia̍h hô hoat-tō͘ sóa khì $2.",
- "movelogpagetext": "Ē-kha lia̍t-chhut hông soá-ūi ê ia̍h.",
+ "movelogpagetext": "Ē-kha lia̍t-chhut hông sóa-ūi ê ia̍h.",
"movereason": "Lí-iû:",
"revertmove": "hôe-tńg",
"delete_and_move_reason": "Thâi-ia̍h hō͘ \"[[$1]]\" thang sóa-ia̍h kòe--lâi",
"selfmove": "Goân piau-tê kap sin piau-tê sio-siâng; bô hoat-tō· sóa.",
- "protectedpagemovewarning": "'''KÉNG-KÒ: Pún ia̍h só tiâu leh. Kan-taⁿ ū hêng-chèng te̍k-koân ê iōng-chiá (sysop) ē-sái soá tín-tāng.'''\nĒ-kha ū choè-kīn ê kì-lio̍k thang chham-khó:",
+ "protectedpagemovewarning": "'''KÉNG-KÒ: Pún ia̍h só tiâu leh. Kan-taⁿ ū hêng-chèng te̍k-koân ê iōng-chiá (sysop) ē-sái sóa tín-tāng.'''\nĒ-kha ū choè-kīn ê kì-lio̍k thang chham-khó:",
"export": "Su-chhut ia̍h",
"exportcuronly": "Hān hiān-chhú-sî ê siu-téng-pún, mài pau-koat kui-ê le̍k-sú",
"allmessages": "Hē-thóng sìn-sit",
"tooltip-ca-delete": "Thâi chit ia̍h",
"tooltip-ca-move": "Sóa chit ia̍h",
"tooltip-ca-watch": "共這頁加入去你的監視單",
- "tooltip-ca-unwatch": "Lí ê kàm-sī-toaⁿ soá tiàu chit ia̍h.",
+ "tooltip-ca-unwatch": "Lí ê kàm-sī-toaⁿ sóa tiàu chit ia̍h.",
"tooltip-search": "Chhoé {{SITENAME}}",
"tooltip-search-go": "Nā ū kāng-miâ--ê, tō khì hit-ia̍h.",
"tooltip-search-fulltext": "Chhoé ū chia-ê jī ê ia̍h",
"autosumm-changed-redirect-target": "Choán-ia̍h bo̍k-phiau kái [[$1]] kòe [[$2]] oân-sêng",
"autosumm-new": "$1 ê ia̍h í-keng kiàn-li̍p",
"watchlistedit-normal-submit": "Mài kàm-sī",
- "watchlistedit-normal-done": "Í-keng uì lí ê kám-sī-toaⁿ soá {{PLURAL:$1|ia̍h}} cháu:",
+ "watchlistedit-normal-done": "Í-keng uì lí ê kám-sī-toaⁿ sóa {{PLURAL:$1|ia̍h}} cháu:",
"watchlisttools-edit": "Khoàⁿ koh kái kàm-sī-toaⁿ",
"watchlisttools-raw": "Kái chhiⁿ ê kàm-sī-toaⁿ",
"signature": "[[{{ns:user}}:$1|$2]] ([[{{ns:user_talk}}:$1|thó-lūn]])",
"userrights-expiry-existing": "ߕߋ߲߭ߕߋ߲߭ ߛߕߊߝߊ߫ ߕߎߡߊ: $3߸ $2",
"userrights-expiry-othertime": "ߕߎ߬ߡߊ߬ ߜߘߍ:",
"userrights-expiry-options": "ߕߟߋ߬ ߁: ߕߟߋ߬ ߁߸ ߞߎ߲߬ߢߐ߰ ߁: ߞߎ߲߬ߢߐ߰ ߁߸ ߞߊߙߏ߫ ߁: ߞߊߙߏ߫ ߁߸ ߞߊߙߏ߫ ߃: ߞߊߙߏ߫ ߃߸ ߞߊߙߏ߫ ߆: ߞߊߙߏ߫ ߆߸ ߛߊ߲߬ ߁: ߛߊ߲߬ ߁",
+ "userrights-invalid-expiry": "ߞߙߎ \"$1\" ߛߕߊ ߝߊ߫ ߕߎߡߊ ߓߍ߲߬ߣߍ߲߫ ߕߍ߫.",
+ "userrights-expiry-in-past": "ߞߙߎ \"$1\" ߛߕߊ ߝߊ߫ ߕߎߡߊ ߓߘߊ߫ ߕߊ߬ߡߌ߲߬.",
"group": "ߞߙߎ:",
"group-user": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ",
"group-autoconfirmed": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ߬ ߞߍߒߖߘߍߦߋ߫ ߟߊߛߙߋߦߊߣߍ߲",
"group-bot": "ߓߏߕ",
"group-sysop": "ߞߎ߲߬ߠߊ߬ߛߌ߰ߟߊ",
"group-bureaucrat": "ߛߓߍߘߟߊߡߐ߮",
+ "group-suppress": "ߛߎ߬ߔߙߋߛߐ߬",
"group-all": "(ߊ߬ ߓߍ߯)",
"group-user-member": "{{GENDER:$1|ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ}}",
"group-autoconfirmed-member": "{{GENDER:$1|ߞߍߒߖߘߍߦߋ߫ ߟߊߛߙߋߦߊߟߌ ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ}}",
"group-bot-member": "{{GENDER:$1|ߓߏߕ}}",
+ "group-sysop-member": "{{GENDER:$1|ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ}}",
"group-bureaucrat-member": "{{GENDER:$1|ߛߓߍߘߟߊߡߐ߮}}",
"grouppage-user": "{{ns:project}}: ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ",
"grouppage-bot": "{{ns:project}}:ߓߏߕ",
"newuserlogpage": "ߖߊ߬ߕߋ߬ߘߊ߬ ߓߘߊ߫ ߟߊߞߊ߬ ߌ ߜߊ߲߬ߞߎ߲߬",
"newuserlogpagetext": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ ߟߊ߫ ߘߎ߲ߛߓߍ߫ ߛߌ߲ߘߌߣߍ߲ ߘߏ߫ ߟߋ߬ ߦߋ߫ ߣߌ߲߬.",
"rightslog": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ ߜߊ߲߬ߞߎ߲߬ ߢߊ߬ ߓߘߍ",
+ "rightslogtext": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ ߤߊߞߍ ߡߊߦߟߍ߬ߡߊ߲߫ ߘߎ߲ߛߓߍ ߟߋ߬ ߦߋ߫ ߣߌ߲߬",
"action-read": "ߞߐߜߍ ߣߌ߲߬ ߘߐߞߊ߬ߙߊ߲߬",
"action-edit": "ߞߐߜߍ ߣߌ߲߬ ߡߊߦߟߍ߬ߡߊ߲߬",
"action-createpage": "ߞߐߜߍ ߣߌ߲߬ ߛߌ߲ߘߌ߫",
"rcfilters-savedqueries-unsetdefault": "ߊ߬ ߓߐ߫ ߓߐߛߎ߲ ߘߐ߫",
"rcfilters-savedqueries-remove": "ߊ߬ ߖߏ߰ߛߌ߬",
"rcfilters-savedqueries-new-name-label": "ߕߐ߮",
+ "rcfilters-savedqueries-apply-label": "ߛߍ߲ߛߍ߲ߟߊ߲ ߛߌ߲ߘߌ߫",
+ "rcfilters-savedqueries-apply-and-setdefault-label": "ߓߐߛߎ߲ ߛߍ߲ߛߍ߲ߟߊ߲ ߛߌ߲ߘߌ߫",
"rcfilters-savedqueries-cancel-label": "ߊ߬ ߘߐߛߊ߬",
+ "rcfilters-savedqueries-add-new-title": "ߕߋ߲߬ߕߋ߲߬ ߛߍ߲ߛߍ߲ߟߊ߲ ߟߊ߬ߓߍ߲߬ߢߐ߲߰ߡߊ ߟߊߞߎ߲߬ߘߎ߬",
+ "rcfilters-savedqueries-already-saved": "ߛߍ߲ߛߍ߲ߟߊ߲ ߣߌ߲߬ ߓߘߊ߫ ߓߊ߲߫ ߠߊߞߎ߲߬ߘߎ߬ ߟߊ߫.ߌ ߟߊ߫ ߟߊ߬ߓߍ߲߬ߢߐ߲߰ߡߊ ߡߊߝߊ߬ߟߋ߲߬ ߞߊ߬ ߛߍ߲ߛߍ߲ߟߊ߲߫ ߟߊߞߎ߲߬ߘߎ߬ߣߍ߲ ߘߏ߫ ߛߌ߲ߘߌ߫.",
+ "rcfilters-clear-all-filters": "ߛߍ߲ߛߍ߲ߟߊ߲ ߓߍ߯ ߛߊߣߌ߲ߧߊ߫",
+ "rcfilters-show-new-changes": "ߡߊ߬ߦߟߍ߬ߡߊ߲߬ߠߌ߲߬ ߞߎߘߊ ߟߎ߬ ߦߋ߫ ߞߊ߬ߦߌ߯ $1",
"rcfilters-filterlist-whatsthis": "ߣߌ߲߬ ߦߋ߫ ߓߊ߯ߙߊ߫ ߟߊ߫ ߘߌ߬؟",
"rcfilters-highlightbutton-title": "ߞߐߝߟߌ߫ ߡߊߦߋߙߋ߲ߣߍ߲ ߠߎ߬",
"rcfilters-highlightmenu-title": "ߞߐ߬ߟߐ ߘߏ߫ ߓߊߓߌ߬ߟߊ߬",
"recentchangeslinked-summary": "ߞߐߜߍ ߕߐ߮ ߟߊߘߏ߲߬߸ ߞߊ߬ ߞߐߜߍ ߛߘߌ߬ߜߋ߲ ߡߊ߬ߦߟߍ߬ߡߊ߲߬ߠߌ߲ ߦߋ߫߸ ߥߟߊ߫ \nߞߊ߬ ߝߘߊ߫ ߞߐߜߍ ߣߌ߲߬ ߠߊ߫. (ߖߐ߲߬ߛߊ߬ ߌ ߘߌ߫ ߦߌߟߡߊ ߛߌ߲߬ߝߏ߲ ߠߎ߬ ߦߋ߫߸ ߣߌ߲߬ ߠߊߘߏ߲߬ {{ns:category}}: ߦߌߟߡߊ ߕߐ߮). ߦߟߍ߬ߡߊ߲߬ ߡߍ߲ ߦߋ߫ ߞߐߜߍ ߣߌ߲߬ [[Special:Watchlist|your Watchlist]] ߘߐ߫߸ ߏ߬ ߦߋ߫ <strong>ߛߓߍߘߋ߲߫ ߞߎ߲ߓߊ</strong> ߟߋ߬ ߘߐ߫.",
"recentchangeslinked-page": "ߞߐߜߍ ߕߐ߮:",
"recentchangeslinked-to": "ߞߐߜߍ ߛߘߌ߬ߜߋ߲ ߠߎ߬ ߦߌ߬ߘߊ߬߸ ߞߊ߬ ߞߐߜߍ ߣߌ߬ ߞߋߟߋ߲ߘߌ߫",
+ "recentchanges-page-added-to-category": "[[:$1]] ߓߘߊ߫ ߟߊߘߏ߲߬ ߦߌߟߡߊ ߘߐ߫",
+ "recentchanges-page-removed-from-category": "[[:$1]] ߛߋ߲߬ߓߐ߫ ߦߌߟߡߊ ߘߐ߫",
+ "autochange-username": "ߡߋߘߌߦߊ߫-ߥߞߌ ߞߍߒߖߘߍߦߋ߫ ߡߊߦߟߍߡߊ߲ߠߌ߲",
"upload": "ߞߐߕߐ߮ ߟߊߦߟߍ",
"uploadbtn": "ߞߐߕߐ߮ ߟߊߦߟߍ߬",
+ "reuploaddesc": "ߟߊ߬ߦߟߍ߬ߟߌ ߘߐߛߊ߬ ߊ߬ ߣߌ߫ ߞߵߌ ߞߐߛߊ߬ߦߌ߬ ߟߊ߬ߦߟߍ߬ߟߌ ߖߙߎߡߎ߲ ߘߐ߫",
+ "uploadnologin": "ߌ ߜߊ߲߬ߞߎ߲߬ߣߍ߲߬ ߕߍ߫",
+ "uploadnologintext": "ߖߊ߰ߣߌ߲߫ $1 ߞߊ߬ ߞߐߕߐ߮ ߟߎ߬ ߟߊߦߟߍ߬.",
"uploadlogpage": "ߜߊ߲߬ߞߎ߲߬ߠߌ߲ ߘߏ߫ ߟߊߦߟߍ߬",
"filename": "ߞߐߕߐ߮ ߕߐ߮",
"filedesc": "ߟߊߘߛߏߣߍ߲",
"fileuploadsummary": "ߟߊ߬ߘߛߏ߬ߟߌ:",
"filereuploadsummary": "ߞߐߕߐ߮ ߡߊߦߟߍ߬ߡߊ߲:",
"filesource": "ߛߎ߲:",
+ "empty-file": "ߌ ߣߊ߬ ߞߐߕߐ߮ ߡߍ߲ ߞߙߊߓߊ߫ ߟߊ߫߸ ߊ߬ ߘߐߞߏߟߏ߲ ߠߋ߬ ߕߘߍ߬.",
+ "file-too-large": "ߌ ߟߊ߫ ߞߐߕߐ߮ ߞߙߊߓߊߣߍ߲ ߓߏ߲߬ߓߊ߫ ߕߘߍ߬ ߞߏߖߎ߰߹",
+ "filename-tooshort": "ߞߐߕߐ߮ ߕߐ߮ ߛߘߎ߬ߡߊ߲߬ ߞߏߖߎ߰.",
+ "filetype-banned": "ߞߐߕߐ߮ ߛߎ߯ߦߊ ߟߊߕߐ߲ߣߍ߲߫ ߠߋ߬.",
+ "verification-error": "ߞߐߕߐ߮ ߣߌ߲߬ ߡߊ߫ ߕߊ߬ߡߌ߲߬ ߞߐߕߐ߮ ߝߛߍ߬ߝߛߍ߬ ߦߌߟߊ.",
+ "hookaborted": "ߌ ߕߘߍ߬ ߦߋ߫ ߡߊ߬ߦߟߍ߬ߡߊ߲߬ߠߌ߲ ߡߍ߲ ߞߍ߫ ߞߏ ߘߐ߫߸ ߊ߬ ߘߐߛߊ߬ߣߍ߲߬ ߦߋ߫ ߘߐ߬ߥߙߊ߬ߟߌ ߟߋ߬ ߓߟߏ߫.",
+ "illegal-filename": "ߞߐߕߐ߮ ߕߐ߮ ߟߊߘߌ߬ߢߍ߬ߣߍ߲߬ ߕߍ߫.",
+ "uploadwarning": "ߟߊ߬ߦߟߍ߬ߟߌ ߖߊ߬ߛߙߋ߬ߡߊ߬ߟߊ",
+ "uploadwarning-text": "ߞߐߕߐ߮ ߘߎ߰ߟߊ߬ߘߐ߫ ߞߊ߲ߛߓߍߟߌ ߡߊ߬ߦߟߍ߬ߡߊ߲߫ ߖߊ߰ߣߌ߲߬߸ ߞߵߊ߬ ߡߊߝߍߣߍ߲߫ ߕߎ߲߯.",
"savefile": "ߞߐߕߐ߮ ߟߊߞߎ߲߬ߘߎ߬",
+ "upload-source": "ߞߐߕߐ߮ ߛߎ߲",
+ "sourceurl": "URL ߛߎ߲:",
+ "destfilename": "ߞߐߕߐ߮ ߕߐ߮ ߞߎ߲߬ߕߋߟߋ߲:",
+ "upload-maxfilesize": "ߞߐߕߐ߮ ߢߊ߲ߞߊ߲ ߞߐߘߊ߲: $1",
+ "upload-description": "ߞߐߕߐ߮ ߞߊ߲߬ߛߓߍߟߌ",
+ "upload-options": "ߟߊ߬ߦߟߍ߬ߟߌ ߢߣߊߕߊߟߌ",
+ "watchthisupload": "ߞߐߕߐ߮ ߣߌ߲߬ ߘߐߜߍ߫",
"upload-dialog-title": "ߞߐߕߐ߮ ߟߊߦߟߍ߬",
"upload-dialog-button-cancel": "ߊ߬ ߘߐߛߊ߬",
"upload-dialog-button-back": "ߌ ߞߐߛߊ߬ߦߌ߬",
+ "upload-dialog-button-done": "ߊ߬ ߓߘߊ߫ ߞߍ߫",
+ "upload-dialog-button-save": "ߊ߬ ߟߊߞߎ߲߬ߘߎ߬",
+ "upload-dialog-button-upload": "ߟߊ߬ߦߟߍ߬ߟߌ",
+ "upload-form-label-infoform-title": "ߝߊߙߊ߲ߝߊ߯ߛߟߌ",
+ "upload-form-label-infoform-name": "ߕߐ߮",
+ "upload-form-label-usage-title": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߌ",
+ "upload-form-label-usage-filename": "ߞߐߕߐ߮ ߕߐ߮",
+ "upload-form-label-own-work": "ߒ ߖߘߍ߬ߞߊ߬ߣߌ߲߬ ߓߊ߯ߙߊ ߟߋ߬",
+ "upload-form-label-infoform-categories": "ߦߌߟߡߊ ߟߎ߬",
+ "upload-form-label-infoform-date": "ߕߎ߬ߡߊ߬ߘߊ",
+ "upload-form-label-own-work-message-generic-local": "ߒ ߧߴߊ߬ ߟߊߛߙߋߦߊ߫ ߟߊ߫ ߞߏ߫ ߒ ߧߋ߫ ߞߐߕߐ߮ ߣߌ߲߬ ߠߊߦߟߍ߬ ߞߊ߲߬ ߞߊ߬ ߓߍ߲߬ ߗߋߘߊ ߛߙߊߕߌ ߣߌ߫ ߕߌ߰ߦߊ ߤߊߞߍ ߡߊ߬ {{SITENAME}} ߞߊ߲߬",
+ "backend-fail-delete": "ߞߐߕߐ߮ ߕߴߛߋ߫ ߖߏ߰ߛߌ߬ ߟߊ߫ \"$1\".",
+ "backend-fail-describe": "ߡߋߕߊߘߕߊ ߞߐߕߐ߮ ߕߴߛߋ߫ ߡߊߦߟߍ߬ߡߊ߲߫ ߠߊ߫ \"$1\".",
+ "backend-fail-store": "ߞߐߕߐ߮ \"$1\" ߕߍ߫ ߛߐ߲߬ ߟߊߡߙߊ߬ ߟߊ߫ ߦߊ߲߬ \"$2\"",
+ "backend-fail-copy": "ߊ߬ ߕߍ߫ ߣߊ߬ ߛߐ߲߬ ߠߊ߫ ߞߐߕߐ߮ \"$1\" ߓߊߦߟߍ߬ߡߊ߲߬ ߠߊ߫ ߦߊ߲߬ \"$2\".",
+ "img-auth-nofile": "ߞߐߕߐ߮ \"$1\" ߕߍ߫ ߦߋ߲߬.",
+ "http-request-error": "HTTP ߡߊ߬ߢߌ߬ߣߌ߲߬ߠߌ߲ ߓߘߊ߫ ߗߌߙߏ߲߫ ߝߎ߬ߕߎ߲߬ߕߌ߬ ߡߊߟߐ߲ߓߊߟߌ ߘߏ߫ ߞߏߛߐ߲߬.",
+ "http-read-error": "HTTP ߘߐ߬ߞߊ߬ߙߊ߲߬ߠߌ߲ ߝߎ߬ߕߎ߲߬ߕߌ.",
+ "http-timed-out": "HTTP ߡߊ߬ߢߌ߬ߣߌ߲߬ߠߌ߲ ߕߎ߬ߡߊ ߓߘߊ߫ ߕߊ߬ߡߌ߲߬.",
+ "http-curl-error": "URL: $1 ߕߌߙߌ߲ߠߌ߲ ߝߎ߬ߕߎ߲߬ߕߌ",
+ "http-bad-status": "ߝߙߋߞߋ ߕߘߍ߬ ߦߋ߫ ߦߋ߲߬ HTTP ߡߊߢߌߣߌ߲ߠߌ߲: $1 $2 ߘߐ߫",
+ "http-internal-error": "HTTP ߞߣߐߟߊ ߘߐ߫ ߝߎߕߎ߲ߕߌ.",
+ "upload-curl-error6": "ߌ ߕߍ߫ ߣߊ߬ URL ߡߊߛߐ߬ߘߐ߲߬ ߠߊ߫",
"license": "ߟߊ߬ߘߌ߬ߢߍ߬ߟߌ ߦߴߌ ߘߐ߫:",
"license-header": "ߟߊ߬ߘߌ߬ߢߍ߬ߟߌ ߦߴߌ ߘߐ߫",
"imgfile": "ߞߐߕߐ߮",
"history": "Histórico",
"history_short": "Histórico",
"history_small": "histórico",
- "updatedmarker": "atualizado desde a minha última visita",
+ "updatedmarker": "atualizado desde a sua última visita",
"printableversion": "Versão para impressão",
"permalink": "Hiperligação permanente",
"print": "Imprimir",
"subject-preview": "Náhľad predmetu:",
"previewerrortext": "Pri pokuse o zobrazenie náhľadu došlo k chybe.",
"blockedtitle": "Používateľ je zablokovaný",
- "blockedtext": "'''Vaše používateľské meno alebo IP adresa bola zablokovaná.'''\n\nZablokoval vás správca $1. Udáva tento dôvod:<br />''$2''\n\n* Blokovanie začalo: $8\n* Blokovanie vyprší: $6\n* Kto mal byť zablokovaný: $7\n\nMôžete kontaktovať $1 alebo s jedného z ďalších [[{{MediaWiki:Grouppage-sysop}}|správcov]] a prediskutovať blokovanie.\nUvedomte si, že nemôžete použiť funkciu „{{int:Emailuser}}“, pokiaľ nemáte registrovanú platnú e-mailovú adresu vo svojich [[Special:Preferences|nastaveniach]].\nVaša IP adresa je $3 a ID blokovania je #$5.\nProsím, uveďte oba tieto údaje do každej správy, ktorú posielate.",
+ "blockedtext": "<strong>Vaše používateľské meno alebo IP adresa bola zablokovaná.</strong>\n\nZablokoval vás správca $1.\nUdáva tento dôvod: <em>$2</em>.\n\n* Blokovanie začalo: $8\n* Blokovanie vyprší: $6\n* Kto mal byť zablokovaný: $7\n\nAk chcete prediskutovať blokovanie, kontaktujte $1 alebo iného [[{{MediaWiki:Grouppage-sysop}}|správcu]].\nFunkciu „{{int:emailuser}}“ môžete použiť, iba ak máte registrovanú platnú e-mailovú adresu vo svojich [[Special:Preferences|nastaveniach]] a jej použitie nebolo zablokované.\nVaša súčasná IP adresa je $3 a ID blokovania je #$5.\nProsím, uveďte všetky tieto údaje do každej správy, ktorú posielate.",
"autoblockedtext": "Vaša IP adresa bola automaticky zablokovaná, pretože ju používa iný používateľ, ktorého zablokoval $1.\nUdaný dôvod zablokovania:\n\n:''$2''\n\n* Blokovanie začalo: $8\n* Blokovanie vyprší: $6\n* Blokovanie sa týka: $7\n\nAk potrebujete informácie o blokovaní, môžete kontaktovať $1 alebo niektorého iného\n[[{{MediaWiki:Grouppage-sysop}}|správcu]].\n\nPozn.: Nemôžete použiť funkciu „{{int:emailuser}}“, ak ste si vo svojich\n[[Special:Preferences|používateľských nastaveniach]] nezaregistrovali platnú e-mailovú adresu.\n\nVaša aktuálna IP adresa je $3. ID vášho blokovania je $5.\nProsím, uveďte tieto podrobnosti v akýchkoľvek otázkach, ktoré sa opýtate.",
"systemblockedtext": "Vaša IP adresa bola automaticky zablokovaná.\nUdaný dôvod zablokovania:\n\n:<em>$2</em>\n\n* Blokovanie začalo: $8\n* Blokovanie vyprší: $6\n* Blokovanie sa týka: $7\n\nVaša aktuálna IP adresa je $3.\nProsím, uveďte tieto podrobnosti v akýchkoľvek otázkach, ktoré sa opýtate.",
"blockednoreason": "nebol uvedený dôvod",
"accmailtext": "Náhodne vytvorené heslo pre používateľa [[User talk:$1|$1]] bolo poslané na $2. Je možné ho zmeniť na stránke ''[[Special:ChangePassword|zmena hesla]]'' po prihlásení.",
"newarticle": "(Nový)",
"newarticletext": "Nasledovali ste odkaz, vedúci na stránku, ktorá zatiaľ neexistuje.\nStránku vytvoríte tak, že začnete písať do políčka nižšie (viac informácií nájdete na stránkach [$1 nápovedy]).\nAk ste sa sem dostali nechtiac, kliknite na tlačidlo <strong>späť</strong> vo svojom prehliadači.",
- "anontalkpagetext": "----\n<em>Toto je diskusná stránka anonymného používateľa, ktorý nemá vytvorené svoje konto alebo ho nepoužíva.</em>\nPreto musíme na jeho identifikáciu použiť numerickú IP adresu. Je možné, že takúto IP adresu používajú viacerí používatelia.\nAk ste anonymný používateľ a máte pocit, že vám boli adresované irelevantné diskusné príspevky, [[Special:CreateAccount|vytvorte si konto]] alebo sa [[Special:UserLogin|prihláste]], aby sa zamedzilo budúcim zámenám s inými anonymnými používateľmi.",
+ "anontalkpagetext": "----\n<em>Toto je diskusná stránka anonymného používateľa, ktorý ešte nemá vytvorené svoje konto alebo ho nepoužíva.</em>\nPreto musíme na jeho identifikáciu použiť numerickú IP adresu.\nJe možné, že túto IP adresu používajú viacerí používatelia.\nAk ste anonymný používateľ a máte pocit, že vám boli adresované irelevantné diskusné príspevky, [[Special:CreateAccount|vytvorte si konto]] alebo sa [[Special:UserLogin|prihláste]], aby sa zamedzilo budúcim zámenám s inými anonymnými používateľmi.",
"noarticletext": "Na tejto stránke sa momentálne nenachádza žiadny text.\nMôžete [[Special:Search/{{PAGENAME}}|vyhľadávať názov tejto stránky]] v obsahu iných stránok,\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} vyhľadávať v súvisiacich záznamoch] alebo [{{fullurl:{{FULLPAGENAME}}|action=edit}} vytvoriť túto stránku]</span>.",
"noarticletext-nopermission": "Táto stránka momentálne neobsahuje žiadny text.\nMôžete [[Special:Search/{{PAGENAME}}|hľadať názov tejto stránky]] v texte iných stránok\nalebo <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} hľadať v súvisiacich záznamoch]</span>, ale nemáte oprávnenie túto stránku vytvoriť.",
"missing-revision": "Revízia #$1 stránky s názvom „{{FULLPAGENAME}}“ neexistuje.\n\nPravdepodobne ste nasledovali zastaraný odkaz na historickú verziu stránky, ktorá bola medzičasom odstránená.\nPodrobnosti nájdete v [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} zázname zmazaní].",
"page_first": "prvá",
"page_last": "posledná",
"histlegend": "Porovnanie zmien: označte výberové políčka revízií, ktoré sa majú porovnať a kliknite na tlačidlo dolu.<br />\nLegenda: (aktuálna) = rozdiel oproti aktuálnej verzii,\n(posledná) = rozdiel oproti predchádzajúcej verzii, D = drobná úprava",
- "history-fieldset-title": "Prechádzať históriou",
+ "history-fieldset-title": "Filtrovať revízie",
"history-show-deleted": "Iba zmazané",
"histfirst": "najstaršie",
"histlast": "najnovšie",
"filehist-comment": "komentár",
"imagelinks": "Použitie súboru",
"linkstoimage": "Na tento súbor {{PLURAL:$1|odkazuje nasledujúca stránka|odkazujú nasledujúce $1 stránky|odkazuje nasledujúcich $1 stránok}}:",
- "linkstoimage-more": "Viac ako $1 {{PLURAL:$1|stránka odkazuje|stránky odkazujú|stránok odkazuje}} na tento súbor.\nNasledovný zoznam zobrazuje {{PLURAL:$1|prvú stránku odkazujúcu|prvé $1 stránky odkazujúce|prvých $1 stránok odkazujúcich}} iba na tento súbor.\nMôžete si pozrieť [[Special:WhatLinksHere/$2|úplný zoznam]].",
+ "linkstoimage-more": "Viac ako $1 {{PLURAL:$1|stránka odkazuje|stránky odkazujú|stránok odkazuje}} na tento súbor.\nNasledovný zoznam zobrazuje {{PLURAL:$1|prvú stránku odkazujúcu|prvé $1 stránky odkazujúce|prvých $1 stránok odkazujúcich}} iba na tento súbor.\nK dispozícii je aj [[Special:WhatLinksHere/$2|úplný zoznam]].",
"nolinkstoimage": "Žiadne stránky neobsahujú odkazy na tento súbor.",
"morelinkstoimage": "Zobraziť [[Special:WhatLinksHere/$1|ďalšie odkazy]] na tento súbor.",
"linkstoimage-redirect": "$1 (presmerovanie súboru) $2",
"tog-norollbackdiff": "Mos trego ndrysh pas kryerjes së një rikthkimi",
"tog-useeditwarning": "Më paralajmëro kur lë një redaktim faqeje me ndryshime të paruajtura",
"tog-prefershttps": "Gjithmonë përdorni një lidhje të sigurt kur të kyçur",
+ "tog-showrollbackconfirmation": "Shfaq një komandë konfirmimi kur shtyp mbi butonin e rikthimit të përgjithshëm.",
"underline-always": "Gjithmonë",
"underline-never": "Asnjëherë",
"underline-default": "Parapërcaktuar nga shfletuesi",
"returnto": "Kthehu tek $1",
"tagline": "Nga {{SITENAME}}",
"help": "Ndihmë",
+ "help-mediawiki": "Ndihmë rreth MediaWiki-t",
"search": "Kërko",
"searchbutton": "Kërko",
"go": "Shko",
"history": "Historiku i faqes",
"history_short": "Historiku",
"history_small": "historiku",
- "updatedmarker": "përditësuar që nga vizita ime e fundit",
+ "updatedmarker": "përditësuar që nga vizita e fundit",
"printableversion": "Versioni i printueshëm",
"permalink": "Lidhje e përhershme",
"print": "Printo",
"badarticleerror": "Ky veprim nuk mund të bëhet në këtë faqe.",
"cannotdelete": "Faqja ose skeda $1 nuk mund të fshihej.\nMund të jetë fshirë nga dikush tjetër.",
"cannotdelete-title": "Faqja \"$1\" nuk mund të fshihet",
+ "delete-scheduled": "Faqja \"$1\" është përcaktuar për fshirje.\n\nJu lutemi, kini durim.",
"delete-hook-aborted": "Fshirja u anulua nga togëza.\nNuk jipet shpjegim.",
"no-null-revision": "I pamundur krijimi rishikimi i ri për faqen bosh \"$ 1\"",
"badtitle": "Titull i pasaktë",
"cascadeprotected": "Kjo faqe është mbrojtur nga redaktimi pasi që është përfshirë në {{PLURAL:$1|faqen|faqet}} e mëposhtme që {{PLURAL:$1|është|janë}} mbrojtur sipas metodës \"cascading\":\n$2",
"namespaceprotected": "Nuk ju lejohet redaktimi i faqeve në hapsirën '''$1'''.",
"customcssprotected": "Ju nuk keni leje për të redaktuar këtë faqe CSS, sepse ai përmban cilësimet personale tjetër user's.",
+ "customjsonprotected": "Ju nuk keni leje për ta redaktuar këtë faqe JSON sepse përmban të dhënat personale të një përdoruesi tjetër.",
"customjsprotected": "Ju nuk keni leje për të redaktuar këtë faqe JavaScript, sepse ai përmban cilësimet personale tjetër user's.",
+ "sitecssprotected": "Ju nuk keni leje ta redaktoni këtë faqe CSS sepse ndryshimi mund të prekë të gjithë vizitorët.",
+ "sitejsonprotected": "Ju nuk keni leje ta redaktoni këtë faqe JSON sepse ndryshimi mund të prekë të gjithë vizitorët.",
+ "sitejsprotected": "Ju nuk keni leje ta redaktoni këtë faqe JavaScript sepse ndryshimi mund të prekë të gjithë vizitorët.",
"mycustomcssprotected": "Ju nuk keni leje për të redaktuar këtë faqe CSS.",
+ "mycustomjsonprotected": "Ju nuk keni leje ta redaktoni këtë faqe JSON.",
"mycustomjsprotected": "Ju nuk keni leje për të redaktuar këtë faqe JavaScript .",
"myprivateinfoprotected": "Ti nuk ke leje për të redaktuar të dhënat e tua private.",
"mypreferencesprotected": "Ti nuk ke leje për të ndryshuar preferencat e tua.",
"ns-specialprotected": "Faqet speciale nuk mund të redaktohen.",
"titleprotected": "Ky titull është mbrojtur nga [[User:$1|$1]] dhe nuk mund të krijohet.\nArsyeja e dhënë është <em>$2</em>.",
"filereadonlyerror": "Nuk është në gjendje që të ndryshojë skedarin \"$1\" sepse depoja e skedarit \"$2\" është në formën vetëm-lexim.\n\nAdministratori sistemit i cili e mbylli atë e dha këtë shpjegim: \"$3\".",
+ "invalidtitle": "Titull i pavlefshëm",
"invalidtitle-knownnamespace": "Titull jo i vlefshëm me hapësirën \"$2\" dhe teksti \"$3\"",
"invalidtitle-unknownnamespace": "Titull jo i vlefshëm me numrin e panjohur të hapësirës së emrit $1 dhe tekstit \"$2\"",
"exception-nologin": "I paqasur",
"userpage-userdoesnotexist": "Llogaria e përdoruesit \"<nowiki>$1</nowiki>\" nuk është e regjistruar. \nJu lutem kontrolloni nëse dëshironi të krijoni/redaktoni këtë faqe.",
"userpage-userdoesnotexist-view": "Llogaria i përdoruesit \"$1\" nuk është e regjistruar.",
"blocked-notice-logextract": "Ky përdorues është aktualisht i bllokuar.\nMë poshtë mund t'i referoheni shënimit të regjistruar për bllokimin e fundit:",
- "clearyourcache": "<strong>Shënim:</strong> Pas ruajtjes, juve mund t'iu duhet të anashkaloni \"cache-in\" e shfletuesit tuaj për të parë ndryshimet. \n* <strong>Firefox/Safari:</strong> Mbaj të shtypur <em>Shift</em> ndërkohë që klikon <em>Reload</em>, ose shtyp <em>Ctrl-F5</strong> ose <em>Ctrl-R</em> (<em>⌘-R</em> në Mac)\n* <strong>Google Chrome:</strong> Shtyp <em>Ctrl-Shift-R</em> (<strong>'⌘-R</strong>' në Mac)\n* <strong>Internet Explorer:</strong> Mbaj të shtypur <em>Ctrl</em> ndërkohë që klikon <em>Refresh</em>, ose shtyp <em>Ctrl-F5</em> \n* <strong>Opera:</strong> Shkoni në <em>Menu → Settings</em> (<em>Opera → Preferences</em> në Mac) dhe pastaj në <em>Privacy & security → Clear browsing data → Cached images and files</em>.",
+ "clearyourcache": "<strong>Shënim:</strong> Pas ruajtjes, mund t'iu duhet të pastroni kashenë e shfletuesit tuaj për të parë ndryshimet. \n* <strong>Firefox/Safari:</strong> Mbaj të shtypur <em>Shift</em> ndërkohë që shtyp <em>Reload</em>, ose shtyp <em>Ctrl-F5</strong> ose <em>Ctrl-R</em> (<em>⌘-R</em> në Mac).\n* <strong>Google Chrome:</strong> Shtyp <em>Ctrl-Shift-R</em> (<strong>'⌘-R</strong>' në Mac).\n* <strong>Internet Explorer:</strong> Mbaj të shtypur <em>Ctrl</em> ndërkohë që shtyp <em>Refresh</em>, ose shtyp <em>Ctrl-F5</em>. \n* <strong>Opera:</strong> Shkoni në <em>Menu → Settings</em> (<em>Opera → Preferences</em> në Mac) dhe pastaj në <em>Privacy & security → Clear browsing data → Cached images and files</em>.",
"usercssyoucanpreview": "'''Këshillë:''' Përdorni butonin '{{int:showpreview}}' për të testuar CSS-në e re para se të ruani ndryshimet e kryera.",
"userjsyoucanpreview": "'''Këshillë:''' Përdorni butonin '{{int:showpreview}}' për të testuar JavaScripting e ri para se të ruani ndryshimet e kryera.",
"usercsspreview": "<strong>Vini re! Ju jeni duke inspektuar CSS-në si përdorues!\nNuk është ruajtur ende!</strong>",
"history": "Историја странице",
"history_short": "Историја",
"history_small": "историја",
- "updatedmarker": "ажÑ\83Ñ\80иÑ\80ано од моÑ\98е последње посете",
+ "updatedmarker": "ажÑ\83Ñ\80иÑ\80ано од ваÑ\88е последње посете",
"printableversion": "Верзија за штампање",
"permalink": "Трајна веза",
"print": "Штампање",
"userlogout": "Одјава",
"notloggedin": "Нисте пријављени",
"userlogin-noaccount": "Немате налог?",
- "userlogin-joinproject": "Придружите се пројекту {{SITENAME}}",
+ "userlogin-joinproject": "Придружите се пројекту",
"createaccount": "Отварање налога",
"userlogin-resetpassword-link": "Заборавили сте лозинку?",
"userlogin-helplink2": "Помоћ при пријављивању",
"restrictionsfield-help": "En IP-adress eller CIDR-intervall per rad. För att aktivera allting, använd<br /><code>0.0.0.0/0</code><br /><code>::/0</code>",
"edit-error-short": "Fel: $1",
"edit-error-long": "Fel:\n\n$1",
+ "specialmute": "Tyst",
+ "specialmute-success": "Dina tystnadsinställningar har uppdateras. Se alla tystade användare i [[Special:Preferences|inställningarna]].",
+ "specialmute-submit": "Bekräfta",
+ "specialmute-label-mute-email": "Tysta e-post från denna användare",
+ "specialmute-header": "Välj dina tystnadsinställningar för {{BIDI:[[User:$1]]}}.",
+ "specialmute-error-invalid-user": "Det begärda användarnamnet kunde inte hittas.",
+ "specialmute-error-email-blacklist-disabled": "Att förhindra användare från att skicka e-post till dig har inte aktiverats.",
+ "specialmute-error-email-preferences": "Du måste bekräfta din e-postadress innan du kan tysta en användare. Du kan göra det i [[Special:Preferences|inställningarna]].",
+ "specialmute-email-footer": "[$1 Hantera e-postinställningar för {{BIDI:$2}}.]",
+ "specialmute-login-required": "Logga in för att ändra dina tystnadsinställningar.",
"revid": "sidversion $1",
"pageid": "sid-ID $1",
"interfaceadmin-info": "$1\n\nBehörigheter för att redigera CSS/JS/JSON-filer för hela webbplatsen separerades nyligen från rättigheten <code>editinterface</code>. Om du inte förstår varför du får detta felmeddelande, se [[mw:MediaWiki_1.32/interface-admin]].",
"blocklist-userblocks": "Hesap engellemelerini gizle",
"blocklist-tempblocks": "Geçici engellemeleri gizle",
"blocklist-addressblocks": "Tek IP engellemelerini gizle",
+ "blocklist-type": "Tür:",
+ "blocklist-type-opt-all": "Hepsi",
+ "blocklist-type-opt-sitewide": "Site genelinde",
+ "blocklist-type-opt-partial": "Kısmi",
"blocklist-rangeblocks": "Dizi bloklarını gizle",
"blocklist-timestamp": "Tarih",
"blocklist-target": "Hedef",
"accmailtext": "「[[User talk:$1|$1]]」嘅隨機產生密碼已經寄咗去 $2。\n\n呢個密碼可以響簽到咗之後嘅<em>[[Special:ChangePassword|改密碼]]</em> 版度改佢。",
"newarticle": "(新)",
"newarticletext": "你連連過嚟嘅頁面重未存在。\n要起版新嘅,請你喺下面嗰格度輸入。(睇睇[$1 自助版]拎多啲資料。)\n如果你係唔覺意嚟到呢度,撳一次你個瀏覽器'''返轉頭'''個掣。",
- "anontalkpagetext": "----\n<em>呢度係匿名用戶嘅討論頁,佢可能係重未開戶口,或者佢重唔識開戶口。</em>\n我哋會用數字表示嘅IP地址嚟代表佢。\n一個IP地址係可以由幾個用戶夾來用。\n如果你係匿名用戶,同覺得呢啲留言係同你冇關係嘅話,唔該去[[Special:CreateAccount|開一個新戶口]]或[[Special:UserLogin|登入]],避免喺以後嘅留言會同埋其它用戶混淆。",
+ "anontalkpagetext": "----\n<em>呢度係位匿名用戶嘅討論頁,佢可能係重未開戶口,或者佢唔想開戶口。</em>\n因此,我哋會用數字表示嘅IP地址嚟代表佢。\n一個IP地址係可以由幾個用戶夾來用。\n如果你係匿名用戶,同覺得呢啲留言係同你冇關係嘅話,唔該去[[Special:CreateAccount|開一個新戶口]]或[[Special:UserLogin|登入]],避免喺以後嘅留言會同埋其它用戶混淆。",
"noarticletext": "喺呢一頁而家並冇任何嘅文字,你可以喺其它嘅頁面中[[Special:Search/{{PAGENAME}}|搵呢一頁嘅標題]],\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} 搵有關嘅日誌],\n或者[{{fullurl:{{FULLPAGENAME}}|action=edit}} 編輯呢一版]</span>。",
"noarticletext-nopermission": "呢一頁而家冇任何文字,你可以喺其它嘅頁面中[[Special:Search/{{PAGENAME}}|搵呢一頁嘅標題]],或者<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} 搵有關嘅日誌]</span>。",
"missing-revision": "The revision #$1 of the page named \"{{FULLPAGENAME}}\" does not exist.\n\nThis is usually caused by following an outdated history link to a page that has been deleted.\nDetails can be found in the [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} deletion log].\n\n《{{FULLPAGENAME}}》嘅編輯#$1唔存在。\n\n恁通常係因為一條過徂時嘅鏈接帶徂閣下去一個已經刪除徂嘅版。\n詳情請查閱[{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} 刪文紀錄]。",
"94rain",
"Viztor",
"Ps2049",
- "Suchichi02"
+ "Suchichi02",
+ "神樂坂秀吉"
]
},
"tog-underline": "链接下划线:",
"history": "页面历史",
"history_short": "历史",
"history_small": "历史",
- "updatedmarker": "æ\9b´æ\96°äº\8eæ\88\91上次访问后",
+ "updatedmarker": "æ\9b´æ\96°äº\8eæ\82¨上次访问后",
"printableversion": "可打印版本",
"permalink": "固定链接",
"print": "打印",
"autoblockedtext": "您的IP地址因曾被一位被$1封禁的用户使用而被自动封禁。封禁原因:\n\n:<em>$2</em>\n\n* 开始时间:$8\n* 到期时间:$6\n* 目标用户:$7\n\n您可以联系$1或其他[[{{MediaWiki:Grouppage-sysop}}|管理员]]申诉该封禁。\n\n请注意,只有当您已在[[Special:Preferences|系统设置]]确认了电子邮件地址且未被禁止使用“{{int:emailuser}}”功能时,才能发送电子邮件联系管理员。\n\n您当前的IP地址为$3,该封禁ID为#$5。请在您做出的任何查询中包含所有上述详情。",
"systemblockedtext": "您的用户名或IP地址已被MediaWiki自动封禁。封禁原因:\n\n:<em>$2</em>\n\n* 开始时间:$8\n* 到期时间:$6\n* 目标用户:$7\n\n您当前的IP地址是$3。请在您做出的任何查询中包含所有上述详情。",
"blockednoreason": "未给出原因",
+ "blockedtext-composite": "您的用户名或IP地址已被封禁。封禁原因:\n\n:<em>$2</em>\n\n* 开始时间:$8\n* 到期时间:$6\n\n您当前的IP地址是$3。请在您做出的任何查询中包含所有上述详情。",
"whitelistedittext": "请$1以编辑页面。",
"confirmedittext": "您必须确认您的电子邮件地址才能编辑页面。请通过[[Special:Preferences|系统设置]]设置并确认您的电子邮件地址。",
"nosuchsectiontitle": "没有这个段落",
"restrictionsfield-help": "每行一个IP地址或CIDR段。要启用任何地址或地址段,可使用:<pre>0.0.0.0/0\n::/0</pre>",
"edit-error-short": "错误:$1",
"edit-error-long": "错误:\n\n$1",
+ "specialmute": "屏蔽",
+ "specialmute-submit": "确认",
"revid": "修订版本$1",
"pageid": "页面ID$1",
"interfaceadmin-info": "$1\n\n编辑全站CSS/JS/JSON文件的权限刚刚从<code>editinterface</code>权限中拆分。如果您不知道为何收到此错误,请参见[[mw:MediaWiki_1.32/interface-admin]]。",
"$IP/",
];
$extraFiles = [
- "$IP/tests/phpunit/MediaWikiTestCase.php",
+ "$IP/tests/phpunit/MediaWikiIntegrationTestCase.php",
];
foreach ( $recurseDirs as $dir ) {
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit bootstrap="tests/phpunit/bootstrap.php"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.8/phpunit.xsd"
+
+ colors="true"
+ backupGlobals="false"
+ convertErrorsToExceptions="true"
+ convertNoticesToExceptions="true"
+ convertWarningsToExceptions="true"
+ forceCoversAnnotation="true"
+ stopOnFailure="false"
+ timeoutForSmallTests="10"
+ timeoutForMediumTests="30"
+ timeoutForLargeTests="60"
+ beStrictAboutTestsThatDoNotTestAnything="true"
+ beStrictAboutOutputDuringTests="true"
+ beStrictAboutTestSize="true"
+ verbose="false">
+ <php>
+ <ini name="memory_limit" value="512M" />
+ </php>
+ <testsuites>
+ <testsuite name="unit">
+ <directory>tests/phpunit/unit</directory>
+ </testsuite>
+ <testsuite name="integration">
+ <directory>tests/phpunit/integration</directory>
+ </testsuite>
+ </testsuites>
+ <groups>
+ <exclude>
+ <group>Broken</group>
+ </exclude>
+ </groups>
+ <filter>
+ <whitelist addUncoveredFilesFromWhitelist="true">
+ <directory suffix=".php">includes</directory>
+ <directory suffix=".php">languages</directory>
+ <directory suffix=".php">maintenance</directory>
+ <exclude>
+ <directory suffix=".php">languages/messages</directory>
+ <file>languages/data/normalize-ar.php</file>
+ <file>languages/data/normalize-ml.php</file>
+ </exclude>
+ </whitelist>
+ </filter>
+ <listeners>
+ <listener class="JohnKary\PHPUnit\Listener\SpeedTrapListener">
+ <arguments>
+ <array>
+ <element key="slowThreshold">
+ <integer>50</integer>
+ </element>
+ <element key="reportLength">
+ <integer>50</integer>
+ </element>
+ </array>
+ </arguments>
+ </listener>
+ </listeners>
+</phpunit>
}
$thead.append( this );
} );
- $table.find( ' > tbody:first' ).before( $thead );
+ $table.find( ' > tbody' ).first().before( $thead );
}
if ( !$table.get( 0 ).tFoot ) {
$tfoot = $( '<tfoot>' );
headerIndex,
exploded,
$tableHeaders = $( [] ),
- $tableRows = $( 'thead:eq(0) > tr', table );
+ $tableRows = $( table ).find( 'thead' ).eq( 0 ).find( '> tr' );
if ( $tableRows.length <= 1 ) {
$tableHeaders = $tableRows.children( 'th' );
}
} else {
// The toggle-link will be in one of the cells (td or th) of the first row
- $firstItem = $collapsible.find( 'tr:first th, tr:first td' );
+ $firstItem = $collapsible.find( 'tr' ).first().find( 'th, td' );
$toggle = $firstItem.find( '> .mw-collapsible-toggle' );
// If theres no toggle link, add it to the last cell
$collapsible.before( $toggle );
} else if ( $collapsible.is( 'ul' ) || $collapsible.is( 'ol' ) ) {
// The toggle-link will be in the first list-item
- $firstItem = $collapsible.find( 'li:first' );
+ $firstItem = $collapsible.find( 'li' ).first();
$toggle = $firstItem.find( '> .mw-collapsible-toggle' );
// If theres no toggle link, add it
if ( !result.get || selected.get( 0 ) !== result.get( 0 ) ) {
if ( result === 'prev' ) {
if ( selected.hasClass( 'suggestions-special' ) ) {
- result = context.data.$container.find( '.suggestions-result:last' );
+ result = context.data.$container.find( '.suggestions-result' ).last();
} else {
result = selected.prev();
if ( !( result.length && result.hasClass( 'suggestions-result' ) ) ) {
if ( context.data.$container.find( '.suggestions-special' ).html() !== '' ) {
result = context.data.$container.find( '.suggestions-special' );
} else {
- result = context.data.$container.find( '.suggestions-results .suggestions-result:last' );
+ result = context.data.$container.find( '.suggestions-results .suggestions-result' ).last();
}
}
}
} else if ( result === 'next' ) {
if ( selected.length === 0 ) {
// No item selected, go to the first one
- result = context.data.$container.find( '.suggestions-results .suggestions-result:first' );
+ result = context.data.$container.find( '.suggestions-results .suggestions-result' ).first();
if ( result.length === 0 && context.data.$container.find( '.suggestions-special' ).html() !== '' ) {
// No suggestion exists, go to the special one directly
result = context.data.$container.find( '.suggestions-special' );
/**
* Get the file title from an image element
*
- * var title = mw.Title.newFromImg( $( 'img:first' ) );
+ * var title = mw.Title.newFromImg( imageNode );
*
* @static
* @param {HTMLElement|jQuery} img The image to use as a base
// can change where they are output).
if ( !document.getElementById( 'p-lang' ) && document.getElementById( 'p-tb' ) && mw.config.get( 'skin' ) === 'vector' ) {
- $( '.portal:last' ).after(
+ $( '.portal' ).last().after(
$( '<div>' ).attr( {
class: 'portal',
id: 'p-lang',
getExpiryInputs().on( 'input change', updateExpiry );
getLevelSelectors().on( 'change', updateLevels );
- $( '#mwProtectSet > tbody > tr:first' ).after( $row );
+ $( '#mwProtectSet > tbody > tr' ).first().after( $row );
// If there is only one protection type, there is nothing to chain
if ( $( '[id ^= mw-protect-table-]' ).length > 1 ) {
// Note that if we do have a real image, using this method will generally
// give the same answer, but can be different in the case of a very
// narrow image where extra padding is added.
- imgHeight = $this.children().children( 'div:first' ).height();
- imgWidth = $this.children().children( 'div:first' ).width();
+ imgHeight = $this.children().children( 'div' ).first().height();
+ imgWidth = $this.children().children( 'div' ).first().width();
}
// Hack to make an edge case work ok
originalText = $emailLabel.text(),
requiredText = mw.message( 'createacct-emailrequired' ).text(),
$createByMailCheckbox = $( '#wpCreateaccountMail' ),
- $beforePwds = $( '.mw-row-password:first' ).prev(),
+ $beforePwds = $( '.mw-row-password' ).first().prev(),
$pwds;
function updateForCheckbox() {
// Hide/show the table of contents element
function toggleToc() {
- if ( $tocList.is( ':hidden' ) ) {
+ if ( $this.hasClass( 'tochidden' ) ) {
// FIXME: Use CSS transitions
// eslint-disable-next-line no-jquery/no-slide
$tocList.slideDown( 'fast' );
e.keyCode === OO.ui.Keys.UP ? -1 : 1, 'wrap' )
);
}
- if ( $field.is( ':input' ) ) {
+ if ( $field.is( 'input' ) ) {
$field.trigger( 'select' );
}
return false;
if ( this.getValueAsDate() === null ) {
this.setValue( this.formatter.getDefaultDate() );
}
- if ( $field.is( ':input' ) ) {
+ if ( $field.is( 'input' ) ) {
$field.trigger( 'select' );
}
'MediaWikiPHPUnitCommand' => "$testDir/phpunit/MediaWikiPHPUnitCommand.php",
'MediaWikiPHPUnitResultPrinter' => "$testDir/phpunit/MediaWikiPHPUnitResultPrinter.php",
'MediaWikiPHPUnitTestListener' => "$testDir/phpunit/MediaWikiPHPUnitTestListener.php",
- 'MediaWikiTestCase' => "$testDir/phpunit/MediaWikiTestCase.php",
+ 'MediaWikiTestCase' => "$testDir/phpunit/MediaWikiIntegrationTestCase.php",
'MediaWikiUnitTestCase' => "$testDir/phpunit/MediaWikiUnitTestCase.php",
+ 'MediaWikiIntegrationTestCase' => "$testDir/phpunit/MediaWikiIntegrationTestCase.php",
'MediaWikiTestResult' => "$testDir/phpunit/MediaWikiTestResult.php",
'MediaWikiTestRunner' => "$testDir/phpunit/MediaWikiTestRunner.php",
'PHPUnit4And6Compat' => "$testDir/phpunit/PHPUnit4And6Compat.php",
--- /dev/null
+<?php
+
+use MediaWiki\Logger\LegacySpi;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\Logger\MonologSpi;
+use MediaWiki\Logger\LogCapturingSpi;
+use MediaWiki\MediaWikiServices;
+use Psr\Log\LoggerInterface;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\IMaintainableDatabase;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @since 1.18
+ *
+ * Extend this class if you are testing classes which access global variables, methods, services
+ * or a storage backend.
+ *
+ * Consider using MediaWikiUnitTestCase and mocking dependencies if your code uses dependency
+ * injection and does not access any globals.
+ */
+abstract class MediaWikiIntegrationTestCase extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ /**
+ * The original service locator. This is overridden during setUp().
+ *
+ * @var MediaWikiServices|null
+ */
+ private static $originalServices;
+
+ /**
+ * The local service locator, created during setUp().
+ * @var MediaWikiServices
+ */
+ private $localServices;
+
+ /**
+ * $called tracks whether the setUp and tearDown method has been called.
+ * class extending MediaWikiTestCase usually override setUp and tearDown
+ * but forget to call the parent.
+ *
+ * The array format takes a method name as key and anything as a value.
+ * By asserting the key exist, we know the child class has called the
+ * parent.
+ *
+ * This property must be private, we do not want child to override it,
+ * they should call the appropriate parent method instead.
+ */
+ private $called = [];
+
+ /**
+ * @var TestUser[]
+ * @since 1.20
+ */
+ public static $users;
+
+ /**
+ * Primary database
+ *
+ * @var Database
+ * @since 1.18
+ */
+ protected $db;
+
+ /**
+ * @var array
+ * @since 1.19
+ */
+ protected $tablesUsed = []; // tables with data
+
+ private static $useTemporaryTables = true;
+ private static $reuseDB = false;
+ private static $dbSetup = false;
+ private static $oldTablePrefix = '';
+
+ /**
+ * Original value of PHP's error_reporting setting.
+ *
+ * @var int
+ */
+ private $phpErrorLevel;
+
+ /**
+ * Holds the paths of temporary files/directories created through getNewTempFile,
+ * and getNewTempDirectory
+ *
+ * @var array
+ */
+ private $tmpFiles = [];
+
+ /**
+ * Holds original values of MediaWiki configuration settings
+ * to be restored in tearDown().
+ * See also setMwGlobals().
+ * @var array
+ */
+ private $mwGlobals = [];
+
+ /**
+ * Holds list of MediaWiki configuration settings to be unset in tearDown().
+ * See also setMwGlobals().
+ * @var array
+ */
+ 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[]
+ */
+ private $loggers = [];
+
+ /**
+ * The CLI arguments passed through from phpunit.php
+ * @var array
+ */
+ private $cliArgs = [];
+
+ /**
+ * Holds a list of services that were overridden with setService(). Used for printing an error
+ * if overrideMwServices() overrides a service that was previously set.
+ * @var string[]
+ */
+ private $overriddenServices = [];
+
+ /**
+ * Table name prefixes. Oracle likes it shorter.
+ */
+ const DB_PREFIX = 'unittest_';
+ const ORA_DB_PREFIX = 'ut_';
+
+ /**
+ * @var array
+ * @since 1.18
+ */
+ protected $supportedDBs = [
+ 'mysql',
+ 'sqlite',
+ 'postgres',
+ 'oracle'
+ ];
+
+ public function __construct( $name = null, array $data = [], $dataName = '' ) {
+ parent::__construct( $name, $data, $dataName );
+
+ $this->backupGlobals = false;
+ $this->backupStaticAttributes = false;
+ }
+
+ public function __destruct() {
+ // Complain if self::setUp() was called, but not self::tearDown()
+ // $this->called['setUp'] will be checked by self::testMediaWikiTestCaseParentSetupCalled()
+ if ( isset( $this->called['setUp'] ) && !isset( $this->called['tearDown'] ) ) {
+ throw new MWException( static::class . "::tearDown() must call parent::tearDown()" );
+ }
+ }
+
+ private static function initializeForStandardPhpunitEntrypointIfNeeded() {
+ if ( function_exists( 'wfRequireOnceInGlobalScope' ) ) {
+ $IP = realpath( __DIR__ . '/../..' );
+ wfRequireOnceInGlobalScope( "$IP/includes/Defines.php" );
+ wfRequireOnceInGlobalScope( "$IP/includes/DefaultSettings.php" );
+ wfRequireOnceInGlobalScope( "$IP/includes/GlobalFunctions.php" );
+ wfRequireOnceInGlobalScope( "$IP/includes/Setup.php" );
+ wfRequireOnceInGlobalScope( "$IP/tests/common/TestsAutoLoader.php" );
+ TestSetup::applyInitialConfig();
+ }
+ }
+
+ public static function setUpBeforeClass() {
+ parent::setUpBeforeClass();
+ \PHPUnit\Framework\Assert::assertFileExists( 'LocalSettings.php' );
+ self::initializeForStandardPhpunitEntrypointIfNeeded();
+
+ // Get the original service locator
+ if ( !self::$originalServices ) {
+ self::$originalServices = MediaWikiServices::getInstance();
+ }
+ }
+
+ /**
+ * Convenience method for getting an immutable test user
+ *
+ * @since 1.28
+ *
+ * @param string|string[] $groups Groups the test user should be in.
+ * @return TestUser
+ */
+ public static function getTestUser( $groups = [] ) {
+ return TestUserRegistry::getImmutableTestUser( $groups );
+ }
+
+ /**
+ * Convenience method for getting a mutable test user
+ *
+ * @since 1.28
+ *
+ * @param string|string[] $groups Groups the test user should be added in.
+ * @return TestUser
+ */
+ public static function getMutableTestUser( $groups = [] ) {
+ return TestUserRegistry::getMutableTestUser( __CLASS__, $groups );
+ }
+
+ /**
+ * Convenience method for getting an immutable admin test user
+ *
+ * @since 1.28
+ *
+ * @param string[] $groups Groups the test user should be added to.
+ * @return TestUser
+ */
+ public static function getTestSysop() {
+ return self::getTestUser( [ 'sysop', 'bureaucrat' ] );
+ }
+
+ /**
+ * Returns a WikiPage representing an existing page.
+ *
+ * @since 1.32
+ *
+ * @param Title|string|null $title
+ * @return WikiPage
+ * @throws MWException If this test cases's needsDB() method doesn't return true.
+ * Test cases can use "@group Database" to enable database test support,
+ * or list the tables under testing in $this->tablesUsed, or override the
+ * needsDB() method.
+ */
+ protected function getExistingTestPage( $title = null ) {
+ if ( !$this->needsDB() ) {
+ throw new MWException( 'When testing which pages, the test cases\'s needsDB()' .
+ ' method should return true. Use @group Database or $this->tablesUsed.' );
+ }
+
+ $title = ( $title === null ) ? 'UTPage' : $title;
+ $title = is_string( $title ) ? Title::newFromText( $title ) : $title;
+ $page = WikiPage::factory( $title );
+
+ if ( !$page->exists() ) {
+ $user = self::getTestSysop()->getUser();
+ $page->doEditContent(
+ new WikitextContent( 'UTContent' ),
+ 'UTPageSummary',
+ EDIT_NEW | EDIT_SUPPRESS_RC,
+ false,
+ $user
+ );
+ }
+
+ return $page;
+ }
+
+ /**
+ * Returns a WikiPage representing a non-existing page.
+ *
+ * @since 1.32
+ *
+ * @param Title|string|null $title
+ * @return WikiPage
+ * @throws MWException If this test cases's needsDB() method doesn't return true.
+ * Test cases can use "@group Database" to enable database test support,
+ * or list the tables under testing in $this->tablesUsed, or override the
+ * needsDB() method.
+ */
+ protected function getNonexistingTestPage( $title = null ) {
+ if ( !$this->needsDB() ) {
+ throw new MWException( 'When testing which pages, the test cases\'s needsDB()' .
+ ' method should return true. Use @group Database or $this->tablesUsed.' );
+ }
+
+ $title = ( $title === null ) ? 'UTPage-' . rand( 0, 100000 ) : $title;
+ $title = is_string( $title ) ? Title::newFromText( $title ) : $title;
+ $page = WikiPage::factory( $title );
+
+ if ( $page->exists() ) {
+ $page->doDeleteArticle( 'Testing' );
+ }
+
+ return $page;
+ }
+
+ /**
+ * @deprecated since 1.32
+ */
+ public static function prepareServices( Config $bootstrapConfig ) {
+ }
+
+ /**
+ * Create a config suitable for testing, based on a base config, default overrides,
+ * and custom overrides.
+ *
+ * @param Config|null $baseConfig
+ * @param Config|null $customOverrides
+ *
+ * @return Config
+ */
+ private static function makeTestConfig(
+ Config $baseConfig = null,
+ Config $customOverrides = null
+ ) {
+ $defaultOverrides = new HashConfig();
+
+ if ( !$baseConfig ) {
+ $baseConfig = self::$originalServices->getBootstrapConfig();
+ }
+
+ /* Some functions require some kind of caching, and will end up using the db,
+ * which we can't allow, as that would open a new connection for mysql.
+ * Replace with a HashBag. They would not be going to persist anyway.
+ */
+ $hashCache = [ 'class' => HashBagOStuff::class, 'reportDupes' => false ];
+ $objectCaches = [
+ CACHE_DB => $hashCache,
+ CACHE_ACCEL => $hashCache,
+ CACHE_MEMCACHED => $hashCache,
+ 'apc' => $hashCache,
+ 'apcu' => $hashCache,
+ 'wincache' => $hashCache,
+ ] + $baseConfig->get( 'ObjectCaches' );
+
+ $defaultOverrides->set( 'ObjectCaches', $objectCaches );
+ $defaultOverrides->set( 'MainCacheType', CACHE_NONE );
+ $defaultOverrides->set( 'JobTypeConf', [ 'default' => [ 'class' => JobQueueMemory::class ] ] );
+
+ // Use a fast hash algorithm to hash passwords.
+ $defaultOverrides->set( 'PasswordDefault', 'A' );
+
+ $testConfig = $customOverrides
+ ? new MultiConfig( [ $customOverrides, $defaultOverrides, $baseConfig ] )
+ : new MultiConfig( [ $defaultOverrides, $baseConfig ] );
+
+ return $testConfig;
+ }
+
+ /**
+ * @param ConfigFactory $oldFactory
+ * @param Config[] $configurations
+ *
+ * @return Closure
+ */
+ private static function makeTestConfigFactoryInstantiator(
+ ConfigFactory $oldFactory,
+ array $configurations
+ ) {
+ return function ( MediaWikiServices $services ) use ( $oldFactory, $configurations ) {
+ $factory = new ConfigFactory();
+
+ // clone configurations from $oldFactory that are not overwritten by $configurations
+ $namesToClone = array_diff(
+ $oldFactory->getConfigNames(),
+ array_keys( $configurations )
+ );
+
+ foreach ( $namesToClone as $name ) {
+ $factory->register( $name, $oldFactory->makeConfig( $name ) );
+ }
+
+ foreach ( $configurations as $name => $config ) {
+ $factory->register( $name, $config );
+ }
+
+ return $factory;
+ };
+ }
+
+ /**
+ * Resets some non-service singleton instances and other static caches. It's not necessary to
+ * reset services here.
+ */
+ public static function resetNonServiceCaches() {
+ global $wgRequest, $wgJobClasses;
+
+ User::resetGetDefaultOptionsForTestsOnly();
+ foreach ( $wgJobClasses as $type => $class ) {
+ JobQueueGroup::singleton()->get( $type )->delete();
+ }
+ JobQueueGroup::destroySingletons();
+
+ ObjectCache::clear();
+ FileBackendGroup::destroySingleton();
+ DeferredUpdates::clearPendingUpdates();
+
+ // TODO: move global state into MediaWikiServices
+ RequestContext::resetMain();
+ if ( session_id() !== '' ) {
+ session_write_close();
+ session_id( '' );
+ }
+
+ $wgRequest = new FauxRequest();
+ MediaWiki\Session\SessionManager::resetCache();
+ }
+
+ public function run( PHPUnit_Framework_TestResult $result = null ) {
+ if ( $result instanceof MediaWikiTestResult ) {
+ $this->cliArgs = $result->getMediaWikiCliArgs();
+ }
+ $this->overrideMwServices();
+
+ if ( $this->needsDB() && !$this->isTestInDatabaseGroup() ) {
+ throw new Exception(
+ get_class( $this ) . ' apparently needsDB but is not in the Database group'
+ );
+ }
+
+ $needsResetDB = false;
+ if ( !self::$dbSetup || $this->needsDB() ) {
+ // set up a DB connection for this test to use
+
+ self::$useTemporaryTables = !$this->getCliArg( 'use-normal-tables' );
+ self::$reuseDB = $this->getCliArg( 'reuse-db' );
+
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+ $this->db = $lb->getConnection( DB_MASTER );
+
+ $this->checkDbIsSupported();
+
+ if ( !self::$dbSetup ) {
+ $this->setupAllTestDBs();
+ $this->addCoreDBData();
+ }
+
+ // TODO: the DB setup should be done in setUpBeforeClass(), so the test DB
+ // is available in subclass's setUpBeforeClass() and setUp() methods.
+ // This would also remove the need for the HACK that is oncePerClass().
+ if ( $this->oncePerClass() ) {
+ $this->setUpSchema( $this->db );
+ $this->resetDB( $this->db, $this->tablesUsed );
+ $this->addDBDataOnce();
+ }
+
+ $this->addDBData();
+ $needsResetDB = true;
+ }
+
+ parent::run( $result );
+
+ // We don't mind if we override already-overridden services during cleanup
+ $this->overriddenServices = [];
+
+ if ( $needsResetDB ) {
+ $this->resetDB( $this->db, $this->tablesUsed );
+ }
+
+ self::restoreMwServices();
+ $this->localServices = null;
+ }
+
+ /**
+ * @return bool
+ */
+ private function oncePerClass() {
+ // Remember current test class in the database connection,
+ // so we know when we need to run addData.
+
+ $class = static::class;
+
+ $first = !isset( $this->db->_hasDataForTestClass )
+ || $this->db->_hasDataForTestClass !== $class;
+
+ $this->db->_hasDataForTestClass = $class;
+ return $first;
+ }
+
+ /**
+ * @since 1.21
+ *
+ * @return bool
+ */
+ public function usesTemporaryTables() {
+ return self::$useTemporaryTables;
+ }
+
+ /**
+ * Obtains a new temporary file name
+ *
+ * The obtained filename is enlisted to be removed upon tearDown
+ *
+ * @since 1.20
+ *
+ * @return string Absolute name of the temporary file
+ */
+ protected function getNewTempFile() {
+ $fileName = tempnam(
+ wfTempDir(),
+ // Avoid backslashes here as they result in inconsistent results
+ // between Windows and other OS, as well as between functions
+ // that try to normalise these in one or both directions.
+ // For example, tempnam rejects directory separators in the prefix which
+ // means it rejects any namespaced class on Windows.
+ // And then there is, wfMkdirParents which normalises paths always
+ // whereas most other PHP and MW functions do not.
+ 'MW_PHPUnit_' . strtr( static::class, [ '\\' => '_' ] ) . '_'
+ );
+ $this->tmpFiles[] = $fileName;
+
+ return $fileName;
+ }
+
+ /**
+ * obtains a new temporary directory
+ *
+ * The obtained directory is enlisted to be removed (recursively with all its contained
+ * files) upon tearDown.
+ *
+ * @since 1.20
+ *
+ * @return string Absolute name of the temporary directory
+ */
+ protected function getNewTempDirectory() {
+ // Starting of with a temporary *file*.
+ $fileName = $this->getNewTempFile();
+
+ // Converting the temporary file to a *directory*.
+ // The following is not atomic, but at least we now have a single place,
+ // where temporary directory creation is bundled and can be improved.
+ unlink( $fileName );
+ // If this fails for some reason, PHP will warn and fail the test.
+ mkdir( $fileName, 0777, /* recursive = */ true );
+
+ return $fileName;
+ }
+
+ protected function setUp() {
+ parent::setUp();
+ $this->called['setUp'] = true;
+
+ $this->phpErrorLevel = intval( ini_get( 'error_reporting' ) );
+
+ $this->overriddenServices = [];
+
+ // Cleaning up temporary files
+ foreach ( $this->tmpFiles as $fileName ) {
+ if ( is_file( $fileName ) || ( is_link( $fileName ) ) ) {
+ unlink( $fileName );
+ } elseif ( is_dir( $fileName ) ) {
+ wfRecursiveRemoveDir( $fileName );
+ }
+ }
+
+ if ( $this->needsDB() && $this->db ) {
+ // Clean up open transactions
+ while ( $this->db->trxLevel() > 0 ) {
+ $this->db->rollback( __METHOD__, 'flush' );
+ }
+ // Check for unsafe queries
+ if ( $this->db->getType() === 'mysql' ) {
+ $this->db->query( "SET sql_mode = 'STRICT_ALL_TABLES'", __METHOD__ );
+ }
+ }
+
+ // Reset all caches between tests.
+ self::resetNonServiceCaches();
+
+ // XXX: reset maintenance triggers
+ // Hook into period lag checks which often happen in long-running scripts
+ $lbFactory = $this->localServices->getDBLoadBalancerFactory();
+ Maintenance::setLBFactoryTriggers( $lbFactory, $this->localServices->getMainConfig() );
+
+ ob_start( 'MediaWikiTestCase::wfResetOutputBuffersBarrier' );
+ }
+
+ protected function addTmpFiles( $files ) {
+ $this->tmpFiles = array_merge( $this->tmpFiles, (array)$files );
+ }
+
+ // @todo Make const when we no longer support HHVM (T192166)
+ private static $namespaceAffectingSettings = [
+ 'wgAllowImageMoving',
+ 'wgCanonicalNamespaceNames',
+ 'wgCapitalLinkOverrides',
+ 'wgCapitalLinks',
+ 'wgContentNamespaces',
+ 'wgExtensionMessagesFiles',
+ 'wgExtensionNamespaces',
+ 'wgExtraNamespaces',
+ 'wgExtraSignatureNamespaces',
+ 'wgNamespaceContentModels',
+ 'wgNamespaceProtection',
+ 'wgNamespacesWithSubpages',
+ 'wgNonincludableNamespaces',
+ 'wgRestrictionLevels',
+ ];
+
+ protected function tearDown() {
+ global $wgRequest, $wgSQLMode;
+
+ $status = ob_get_status();
+ if ( isset( $status['name'] ) &&
+ $status['name'] === 'MediaWikiTestCase::wfResetOutputBuffersBarrier'
+ ) {
+ ob_end_flush();
+ }
+
+ $this->called['tearDown'] = true;
+ // Cleaning up temporary files
+ foreach ( $this->tmpFiles as $fileName ) {
+ if ( is_file( $fileName ) || ( is_link( $fileName ) ) ) {
+ unlink( $fileName );
+ } elseif ( is_dir( $fileName ) ) {
+ wfRecursiveRemoveDir( $fileName );
+ }
+ }
+
+ if ( $this->needsDB() && $this->db ) {
+ // Clean up open transactions
+ while ( $this->db->trxLevel() > 0 ) {
+ $this->db->rollback( __METHOD__, 'flush' );
+ }
+ if ( $this->db->getType() === 'mysql' ) {
+ $this->db->query( "SET sql_mode = " . $this->db->addQuotes( $wgSQLMode ),
+ __METHOD__ );
+ }
+ }
+
+ // Re-enable any disabled deprecation warnings
+ MWDebug::clearLog();
+ // Restore mw globals
+ foreach ( $this->mwGlobals as $key => $value ) {
+ $GLOBALS[$key] = $value;
+ }
+ foreach ( $this->mwGlobalsToUnset as $value ) {
+ unset( $GLOBALS[$value] );
+ }
+ foreach ( $this->iniSettings as $name => $value ) {
+ ini_set( $name, $value );
+ }
+ if (
+ array_intersect( self::$namespaceAffectingSettings, array_keys( $this->mwGlobals ) ) ||
+ array_intersect( self::$namespaceAffectingSettings, $this->mwGlobalsToUnset )
+ ) {
+ $this->resetNamespaces();
+ }
+ $this->mwGlobals = [];
+ $this->mwGlobalsToUnset = [];
+ $this->restoreLoggers();
+
+ // TODO: move global state into MediaWikiServices
+ RequestContext::resetMain();
+ if ( session_id() !== '' ) {
+ session_write_close();
+ session_id( '' );
+ }
+ $wgRequest = new FauxRequest();
+ MediaWiki\Session\SessionManager::resetCache();
+ MediaWiki\Auth\AuthManager::resetCache();
+
+ $phpErrorLevel = intval( ini_get( 'error_reporting' ) );
+
+ if ( $phpErrorLevel !== $this->phpErrorLevel ) {
+ ini_set( 'error_reporting', $this->phpErrorLevel );
+
+ $oldHex = strtoupper( dechex( $this->phpErrorLevel ) );
+ $newHex = strtoupper( dechex( $phpErrorLevel ) );
+ $message = "PHP error_reporting setting was left dirty: "
+ . "was 0x$oldHex before test, 0x$newHex after test!";
+
+ $this->fail( $message );
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * Make sure MediaWikiTestCase extending classes have called their
+ * parent setUp method
+ *
+ * With strict coverage activated in PHP_CodeCoverage, this test would be
+ * marked as risky without the following annotation (T152923).
+ * @coversNothing
+ */
+ final public function testMediaWikiTestCaseParentSetupCalled() {
+ $this->assertArrayHasKey( 'setUp', $this->called,
+ static::class . '::setUp() must call parent::setUp()'
+ );
+ }
+
+ /**
+ * Sets a service, maintaining a stashed version of the previous service to be
+ * restored in tearDown.
+ *
+ * @note Tests must not call overrideMwServices() after calling setService(), since that would
+ * lose the new service instance. Since 1.34, resetServices() can be used instead, which
+ * would reset other services, but retain any services set using setService().
+ * This means that once a service is set using this method, it cannot be reverted to
+ * the original service within the same test method. The original service is restored
+ * in tearDown after the test method has terminated.
+ *
+ * @param string $name
+ * @param object $service The service instance, or a callable that returns the service instance.
+ *
+ * @since 1.27
+ *
+ */
+ protected function setService( $name, $service ) {
+ if ( !$this->localServices ) {
+ throw new Exception( __METHOD__ . ' must be called after MediaWikiTestCase::run()' );
+ }
+
+ if ( $this->localServices !== MediaWikiServices::getInstance() ) {
+ throw new Exception( __METHOD__ . ' will not work because the global MediaWikiServices '
+ . 'instance has been replaced by test code.' );
+ }
+
+ if ( is_callable( $service ) ) {
+ $instantiator = $service;
+ } else {
+ $instantiator = function () use ( $service ) {
+ return $service;
+ };
+ }
+
+ $this->overriddenServices[] = $name;
+
+ $this->localServices->disableService( $name );
+ $this->localServices->redefineService(
+ $name,
+ $instantiator
+ );
+
+ if ( $name === 'ContentLanguage' ) {
+ $this->doSetMwGlobals( [ 'wgContLang' => $this->localServices->getContentLanguage() ] );
+ }
+ }
+
+ /**
+ * Sets a global, maintaining a stashed version of the previous global to be
+ * restored in tearDown
+ *
+ * The key is added to the array of globals that will be reset afterwards
+ * in the tearDown().
+ *
+ * It may be necessary to call resetServices() to allow any changed configuration variables
+ * to take effect on services that get initialized based on these variables.
+ *
+ * @par Example
+ * @code
+ * protected function setUp() {
+ * $this->setMwGlobals( 'wgRestrictStuff', true );
+ * }
+ *
+ * function testFoo() {}
+ *
+ * function testBar() {}
+ * $this->assertTrue( self::getX()->doStuff() );
+ *
+ * $this->setMwGlobals( 'wgRestrictStuff', false );
+ * $this->assertTrue( self::getX()->doStuff() );
+ * }
+ *
+ * function testQuux() {}
+ * @endcode
+ *
+ * @param array|string $pairs Key to the global variable, or an array
+ * of key/value pairs.
+ * @param mixed|null $value Value to set the global to (ignored
+ * if an array is given as first argument).
+ *
+ * @note To allow changes to global variables to take effect on global service instances,
+ * call resetServices().
+ *
+ * @since 1.21
+ */
+ protected function setMwGlobals( $pairs, $value = null ) {
+ if ( is_string( $pairs ) ) {
+ $pairs = [ $pairs => $value ];
+ }
+
+ if ( isset( $pairs['wgContLang'] ) ) {
+ throw new MWException(
+ 'No setting $wgContLang, use setContentLang() or setService( \'ContentLanguage\' )'
+ );
+ }
+
+ $this->doSetMwGlobals( $pairs, $value );
+ }
+
+ /**
+ * An internal method that allows setService() to set globals that tests are not supposed to
+ * touch.
+ */
+ private function doSetMwGlobals( $pairs, $value = null ) {
+ $this->doStashMwGlobals( array_keys( $pairs ) );
+
+ foreach ( $pairs as $key => $value ) {
+ $GLOBALS[$key] = $value;
+ }
+
+ if ( array_intersect( self::$namespaceAffectingSettings, array_keys( $pairs ) ) ) {
+ $this->resetNamespaces();
+ }
+ }
+
+ /**
+ * 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.
+ */
+ private function resetNamespaces() {
+ if ( !$this->localServices ) {
+ throw new Exception( __METHOD__ . ' must be called after MediaWikiTestCase::run()' );
+ }
+
+ if ( $this->localServices !== MediaWikiServices::getInstance() ) {
+ throw new Exception( __METHOD__ . ' will not work because the global MediaWikiServices '
+ . 'instance has been replaced by test code.' );
+ }
+
+ Language::clearCaches();
+ }
+
+ /**
+ * Check if we can back up a value by performing a shallow copy.
+ * Values which fail this test are copied recursively.
+ *
+ * @param mixed $value
+ * @return bool True if a shallow copy will do; false if a deep copy
+ * is required.
+ */
+ private static function canShallowCopy( $value ) {
+ if ( is_scalar( $value ) || $value === null ) {
+ return true;
+ }
+ if ( is_array( $value ) ) {
+ foreach ( $value as $subValue ) {
+ if ( !is_scalar( $subValue ) && $subValue !== null ) {
+ return false;
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ private function doStashMwGlobals( $globalKeys ) {
+ if ( is_string( $globalKeys ) ) {
+ $globalKeys = [ $globalKeys ];
+ }
+
+ foreach ( $globalKeys as $globalKey ) {
+ // NOTE: make sure we only save the global once or a second call to
+ // setMwGlobals() on the same global would override the original
+ // value.
+ if (
+ !array_key_exists( $globalKey, $this->mwGlobals ) &&
+ !array_key_exists( $globalKey, $this->mwGlobalsToUnset )
+ ) {
+ if ( !array_key_exists( $globalKey, $GLOBALS ) ) {
+ $this->mwGlobalsToUnset[$globalKey] = $globalKey;
+ continue;
+ }
+ // NOTE: we serialize then unserialize the value in case it is an object
+ // this stops any objects being passed by reference. We could use clone
+ // and if is_object but this does account for objects within objects!
+ if ( self::canShallowCopy( $GLOBALS[$globalKey] ) ) {
+ $this->mwGlobals[$globalKey] = $GLOBALS[$globalKey];
+ } elseif (
+ // Many MediaWiki types are safe to clone. These are the
+ // ones that are most commonly stashed.
+ $GLOBALS[$globalKey] instanceof Language ||
+ $GLOBALS[$globalKey] instanceof User ||
+ $GLOBALS[$globalKey] instanceof FauxRequest
+ ) {
+ $this->mwGlobals[$globalKey] = clone $GLOBALS[$globalKey];
+ } elseif ( $this->containsClosure( $GLOBALS[$globalKey] ) ) {
+ // Serializing Closure only gives a warning on HHVM while
+ // it throws an Exception on Zend.
+ // Workaround for https://github.com/facebook/hhvm/issues/6206
+ $this->mwGlobals[$globalKey] = $GLOBALS[$globalKey];
+ } else {
+ try {
+ $this->mwGlobals[$globalKey] = unserialize( serialize( $GLOBALS[$globalKey] ) );
+ } catch ( Exception $e ) {
+ $this->mwGlobals[$globalKey] = $GLOBALS[$globalKey];
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * @param mixed $var
+ * @param int $maxDepth
+ *
+ * @return bool
+ */
+ private function containsClosure( $var, $maxDepth = 15 ) {
+ if ( $var instanceof Closure ) {
+ return true;
+ }
+ if ( !is_array( $var ) || $maxDepth === 0 ) {
+ return false;
+ }
+
+ foreach ( $var as $value ) {
+ if ( $this->containsClosure( $value, $maxDepth - 1 ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Merges the given values into a MW global array variable.
+ * Useful for setting some entries in a configuration array, instead of
+ * setting the entire array.
+ *
+ * It may be necessary to call resetServices() to allow any changed configuration variables
+ * to take effect on services that get initialized based on these variables.
+ *
+ * @param string $name The name of the global, as in wgFooBar
+ * @param array $values The array containing the entries to set in that global
+ *
+ * @throws MWException If the designated global is not an array.
+ *
+ * @note To allow changes to global variables to take effect on global service instances,
+ * call resetServices().
+ *
+ * @since 1.21
+ */
+ protected function mergeMwGlobalArrayValue( $name, $values ) {
+ if ( !isset( $GLOBALS[$name] ) ) {
+ $merged = $values;
+ } else {
+ if ( !is_array( $GLOBALS[$name] ) ) {
+ throw new MWException( "MW global $name is not an array." );
+ }
+
+ // NOTE: do not use array_merge, it screws up for numeric keys.
+ $merged = $GLOBALS[$name];
+ foreach ( $values as $k => $v ) {
+ $merged[$k] = $v;
+ }
+ }
+
+ $this->setMwGlobals( $name, $merged );
+ }
+
+ /**
+ * Resets service instances in the global instance of MediaWikiServices.
+ *
+ * In contrast to overrideMwServices(), this does not create a new MediaWikiServices instance,
+ * and it preserves any service instances set via setService().
+ *
+ * The primary use case for this method is to allow changes to global configuration variables
+ * to take effect on services that get initialized based on these global configuration
+ * variables. Similarly, it may be necessary to call resetServices() after calling setService(),
+ * so the newly set service gets picked up by any other service definitions that may use it.
+ *
+ * @see MediaWikiServices::resetServiceForTesting.
+ *
+ * @since 1.34
+ */
+ protected function resetServices() {
+ // Reset but don't destroy service instances supplied via setService().
+ foreach ( $this->overriddenServices as $name ) {
+ $this->localServices->resetServiceForTesting( $name, false );
+ }
+
+ // Reset all services with the destroy flag set.
+ // This will not have any effect on services that had already been reset above.
+ foreach ( $this->localServices->getServiceNames() as $name ) {
+ $this->localServices->resetServiceForTesting( $name, true );
+ }
+
+ self::resetGlobalParser();
+ }
+
+ /**
+ * Installs a new global instance of MediaWikiServices, allowing test cases to override
+ * settings and services.
+ *
+ * This method can be used to set up specific services or configuration as a fixture.
+ * It should not be used to reset services in between stages of a test - instead, the test
+ * should either be split, or resetServices() should be used.
+ *
+ * If called with no parameters, this method restores all services to their default state.
+ * This is done automatically before each test to isolate tests from any modification
+ * to settings and services that may have been applied by previous tests.
+ * That means that the effect of calling overrideMwServices() is undone before the next
+ * call to a test method.
+ *
+ * @note Calling this after having called setService() in the same test method (or the
+ * associated setUp) will result in an MWException.
+ * Tests should use either overrideMwServices() or setService(), but not mix both.
+ * Since 1.34, resetServices() is available as an alternative compatible with setService().
+ *
+ * @since 1.27
+ *
+ * @param Config|null $configOverrides Configuration overrides for the new MediaWikiServices
+ * instance.
+ * @param callable[] $services An associative array of services to re-define. Keys are service
+ * names, values are callables.
+ *
+ * @return MediaWikiServices
+ * @throws MWException
+ */
+ protected function overrideMwServices(
+ Config $configOverrides = null, array $services = []
+ ) {
+ if ( $this->overriddenServices ) {
+ throw new MWException(
+ 'The following services were set and are now being unset by overrideMwServices: ' .
+ implode( ', ', $this->overriddenServices )
+ );
+ }
+ $newInstance = self::installMockMwServices( $configOverrides );
+
+ if ( $this->localServices ) {
+ $this->localServices->destroy();
+ }
+
+ $this->localServices = $newInstance;
+
+ foreach ( $services as $name => $callback ) {
+ $newInstance->redefineService( $name, $callback );
+ }
+
+ self::resetGlobalParser();
+
+ return $newInstance;
+ }
+
+ /**
+ * Creates a new "mock" MediaWikiServices instance, and installs it.
+ * This effectively resets all cached states in services, with the exception of
+ * the ConfigFactory and the DBLoadBalancerFactory service, which are inherited from
+ * the original MediaWikiServices.
+ *
+ * @note The new original MediaWikiServices instance can later be restored by calling
+ * restoreMwServices(). That original is determined by the first call to this method, or
+ * by setUpBeforeClass, whichever is called first. The caller is responsible for managing
+ * and, when appropriate, destroying any other MediaWikiServices instances that may get
+ * replaced when calling this method.
+ *
+ * @param Config|null $configOverrides Configuration overrides for the new MediaWikiServices
+ * instance.
+ *
+ * @return MediaWikiServices the new mock service locator.
+ */
+ public static function installMockMwServices( Config $configOverrides = null ) {
+ // Make sure we have the original service locator
+ if ( !self::$originalServices ) {
+ self::$originalServices = MediaWikiServices::getInstance();
+ }
+
+ if ( !$configOverrides ) {
+ $configOverrides = new HashConfig();
+ }
+
+ $oldConfigFactory = self::$originalServices->getConfigFactory();
+ $oldLoadBalancerFactory = self::$originalServices->getDBLoadBalancerFactory();
+
+ $testConfig = self::makeTestConfig( null, $configOverrides );
+ $newServices = new MediaWikiServices( $testConfig );
+
+ // Load the default wiring from the specified files.
+ // NOTE: this logic mirrors the logic in MediaWikiServices::newInstance.
+ $wiringFiles = $testConfig->get( 'ServiceWiringFiles' );
+ $newServices->loadWiringFiles( $wiringFiles );
+
+ // Provide a traditional hook point to allow extensions to configure services.
+ Hooks::run( 'MediaWikiServices', [ $newServices ] );
+
+ // Use bootstrap config for all configuration.
+ // This allows config overrides via global variables to take effect.
+ $bootstrapConfig = $newServices->getBootstrapConfig();
+ $newServices->resetServiceForTesting( 'ConfigFactory' );
+ $newServices->redefineService(
+ 'ConfigFactory',
+ self::makeTestConfigFactoryInstantiator(
+ $oldConfigFactory,
+ [ 'main' => $bootstrapConfig ]
+ )
+ );
+ $newServices->resetServiceForTesting( 'DBLoadBalancerFactory' );
+ $newServices->redefineService(
+ 'DBLoadBalancerFactory',
+ function ( MediaWikiServices $services ) use ( $oldLoadBalancerFactory ) {
+ return $oldLoadBalancerFactory;
+ }
+ );
+
+ MediaWikiServices::forceGlobalInstance( $newServices );
+
+ self::resetGlobalParser();
+
+ return $newServices;
+ }
+
+ /**
+ * Restores the original, non-mock MediaWikiServices instance.
+ * The previously active MediaWikiServices instance is destroyed,
+ * if it is different from the original that is to be restored.
+ *
+ * @note this if for internal use by test framework code. It should never be
+ * called from inside a test case, a data provider, or a setUp or tearDown method.
+ *
+ * @return bool true if the original service locator was restored,
+ * false if there was nothing too do.
+ */
+ public static function restoreMwServices() {
+ if ( !self::$originalServices ) {
+ return false;
+ }
+
+ $currentServices = MediaWikiServices::getInstance();
+
+ if ( self::$originalServices === $currentServices ) {
+ return false;
+ }
+
+ MediaWikiServices::forceGlobalInstance( self::$originalServices );
+ $currentServices->destroy();
+
+ self::resetGlobalParser();
+
+ return true;
+ }
+
+ /**
+ * If $wgParser has been unstubbed, replace it with a fresh one so it picks up any config
+ * changes. $wgParser is deprecated, but we still support it for now.
+ */
+ private static function resetGlobalParser() {
+ // phpcs:ignore MediaWiki.Usage.DeprecatedGlobalVariables.Deprecated$wgParser
+ global $wgParser;
+ if ( $wgParser instanceof StubObject ) {
+ return;
+ }
+ $wgParser = new StubObject( 'wgParser', function () {
+ return MediaWikiServices::getInstance()->getParser();
+ } );
+ }
+
+ /**
+ * @since 1.27
+ * @param string|Language $lang
+ */
+ public function setUserLang( $lang ) {
+ RequestContext::getMain()->setLanguage( $lang );
+ $this->setMwGlobals( 'wgLang', RequestContext::getMain()->getLanguage() );
+ }
+
+ /**
+ * @since 1.27
+ * @param string|Language $lang
+ */
+ public function setContentLang( $lang ) {
+ if ( $lang instanceof Language ) {
+ $this->setMwGlobals( 'wgLanguageCode', $lang->getCode() );
+ // Set to the exact object requested
+ $this->setService( 'ContentLanguage', $lang );
+ } else {
+ $this->setMwGlobals( 'wgLanguageCode', $lang );
+ // Let the service handler make up the object. Avoid calling setService(), because if
+ // we do, overrideMwServices() will complain if it's called later on.
+ $services = MediaWikiServices::getInstance();
+ $services->resetServiceForTesting( 'ContentLanguage' );
+ $this->doSetMwGlobals( [ 'wgContLang' => $services->getContentLanguage() ] );
+ }
+ }
+
+ /**
+ * Alters $wgGroupPermissions for the duration of the test. Can be called
+ * with an array, like
+ * [ '*' => [ 'read' => false ], 'user' => [ 'read' => false ] ]
+ * or three values to set a single permission, like
+ * $this->setGroupPermissions( '*', 'read', false );
+ *
+ * @since 1.31
+ * @param array|string $newPerms Either an array of permissions to change,
+ * in which case the next two parameters are ignored; or a single string
+ * identifying a group, to use with the next two parameters.
+ * @param string|null $newKey
+ * @param mixed|null $newValue
+ */
+ public function setGroupPermissions( $newPerms, $newKey = null, $newValue = null ) {
+ global $wgGroupPermissions;
+
+ if ( is_string( $newPerms ) ) {
+ $newPerms = [ $newPerms => [ $newKey => $newValue ] ];
+ }
+
+ $newPermissions = $wgGroupPermissions;
+ foreach ( $newPerms as $group => $permissions ) {
+ foreach ( $permissions as $key => $value ) {
+ $newPermissions[$group][$key] = $value;
+ }
+ }
+
+ $this->setMwGlobals( 'wgGroupPermissions', $newPermissions );
+
+ // Reset services so they pick up the new permissions.
+ // Resetting just PermissionManager is not sufficient, since other services may
+ // have the old instance of PermissionManager injected.
+ $this->resetServices();
+ }
+
+ /**
+ *
+ * @since 1.34
+ * Sets the logger for a specified channel, for the duration of the test.
+ * @since 1.27
+ * @param string $channel
+ * @param LoggerInterface $logger
+ */
+ protected function setLogger( $channel, LoggerInterface $logger ) {
+ // TODO: Once loggers are managed by MediaWikiServices, use
+ // resetServiceForTesting() to set loggers.
+
+ $provider = LoggerFactory::getProvider();
+ $wrappedProvider = TestingAccessWrapper::newFromObject( $provider );
+ $singletons = $wrappedProvider->singletons;
+ if ( $provider instanceof MonologSpi ) {
+ if ( !isset( $this->loggers[$channel] ) ) {
+ $this->loggers[$channel] = $singletons['loggers'][$channel] ?? null;
+ }
+ $singletons['loggers'][$channel] = $logger;
+ } elseif ( $provider instanceof LegacySpi || $provider instanceof LogCapturingSpi ) {
+ if ( !isset( $this->loggers[$channel] ) ) {
+ $this->loggers[$channel] = $singletons[$channel] ?? null;
+ }
+ $singletons[$channel] = $logger;
+ } else {
+ throw new LogicException( __METHOD__ . ': setting a logger for ' . get_class( $provider )
+ . ' is not implemented' );
+ }
+ $wrappedProvider->singletons = $singletons;
+ }
+
+ /**
+ * Restores loggers replaced by setLogger().
+ * @since 1.27
+ */
+ private function restoreLoggers() {
+ $provider = LoggerFactory::getProvider();
+ $wrappedProvider = TestingAccessWrapper::newFromObject( $provider );
+ $singletons = $wrappedProvider->singletons;
+ foreach ( $this->loggers as $channel => $logger ) {
+ if ( $provider instanceof MonologSpi ) {
+ if ( $logger === null ) {
+ unset( $singletons['loggers'][$channel] );
+ } else {
+ $singletons['loggers'][$channel] = $logger;
+ }
+ } elseif ( $provider instanceof LegacySpi || $provider instanceof LogCapturingSpi ) {
+ if ( $logger === null ) {
+ unset( $singletons[$channel] );
+ } else {
+ $singletons[$channel] = $logger;
+ }
+ }
+ }
+ $wrappedProvider->singletons = $singletons;
+ $this->loggers = [];
+ }
+
+ /**
+ * @return string
+ * @since 1.18
+ */
+ public function dbPrefix() {
+ return self::getTestPrefixFor( $this->db );
+ }
+
+ /**
+ * @param IDatabase $db
+ * @return string
+ * @since 1.32
+ */
+ public static function getTestPrefixFor( IDatabase $db ) {
+ return $db->getType() == 'oracle' ? self::ORA_DB_PREFIX : self::DB_PREFIX;
+ }
+
+ /**
+ * @return bool
+ * @since 1.18
+ */
+ public function needsDB() {
+ // If the test says it uses database tables, it needs the database
+ return $this->tablesUsed || $this->isTestInDatabaseGroup();
+ }
+
+ /**
+ * @return bool
+ * @since 1.32
+ */
+ protected function isTestInDatabaseGroup() {
+ // If the test class says it belongs to the Database group, it needs the database.
+ // NOTE: This ONLY checks for the group in the class level doc comment.
+ $rc = new ReflectionClass( $this );
+ return (bool)preg_match( '/@group +Database/im', $rc->getDocComment() );
+ }
+
+ /**
+ * Insert a new page.
+ *
+ * Should be called from addDBData().
+ *
+ * @since 1.25 ($namespace in 1.28)
+ * @param string|Title $pageName Page name or title
+ * @param string $text Page's content
+ * @param int|null $namespace Namespace id (name cannot already contain namespace)
+ * @param User|null $user If null, static::getTestSysop()->getUser() is used.
+ * @return array Title object and page id
+ * @throws MWException If this test cases's needsDB() method doesn't return true.
+ * Test cases can use "@group Database" to enable database test support,
+ * or list the tables under testing in $this->tablesUsed, or override the
+ * needsDB() method.
+ */
+ protected function insertPage(
+ $pageName,
+ $text = 'Sample page for unit test.',
+ $namespace = null,
+ User $user = null
+ ) {
+ if ( !$this->needsDB() ) {
+ throw new MWException( 'When testing which pages, the test cases\'s needsDB()' .
+ ' method should return true. Use @group Database or $this->tablesUsed.' );
+ }
+
+ if ( is_string( $pageName ) ) {
+ $title = Title::newFromText( $pageName, $namespace );
+ } else {
+ $title = $pageName;
+ }
+
+ if ( !$user ) {
+ $user = static::getTestSysop()->getUser();
+ }
+ $comment = __METHOD__ . ': Sample page for unit test.';
+
+ $page = WikiPage::factory( $title );
+ $page->doEditContent( ContentHandler::makeContent( $text, $title ), $comment, 0, false, $user );
+
+ return [
+ 'title' => $title,
+ 'id' => $page->getId(),
+ ];
+ }
+
+ /**
+ * Stub. If a test suite needs to add additional data to the database, it should
+ * implement this method and do so. This method is called once per test suite
+ * (i.e. once per class).
+ *
+ * Note data added by this method may be removed by resetDB() depending on
+ * the contents of $tablesUsed.
+ *
+ * To add additional data between test function runs, override prepareDB().
+ *
+ * @see addDBData()
+ * @see resetDB()
+ *
+ * @since 1.27
+ */
+ public function addDBDataOnce() {
+ }
+
+ /**
+ * Stub. Subclasses may override this to prepare the database.
+ * Called before every test run (test function or data set).
+ *
+ * @see addDBDataOnce()
+ * @see resetDB()
+ *
+ * @since 1.18
+ */
+ public function addDBData() {
+ }
+
+ /**
+ * @since 1.32
+ */
+ protected function addCoreDBData() {
+ if ( $this->db->getType() == 'oracle' ) {
+ # Insert 0 user to prevent FK violations
+ # Anonymous user
+ if ( !$this->db->selectField( 'user', '1', [ 'user_id' => 0 ] ) ) {
+ $this->db->insert( 'user', [
+ 'user_id' => 0,
+ 'user_name' => 'Anonymous' ], __METHOD__, [ 'IGNORE' ] );
+ }
+
+ # Insert 0 page to prevent FK violations
+ # Blank page
+ if ( !$this->db->selectField( 'page', '1', [ 'page_id' => 0 ] ) ) {
+ $this->db->insert( 'page', [
+ 'page_id' => 0,
+ 'page_namespace' => 0,
+ 'page_title' => ' ',
+ 'page_restrictions' => null,
+ 'page_is_redirect' => 0,
+ 'page_is_new' => 0,
+ 'page_random' => 0,
+ 'page_touched' => $this->db->timestamp(),
+ 'page_latest' => 0,
+ 'page_len' => 0 ], __METHOD__, [ 'IGNORE' ] );
+ }
+ }
+
+ SiteStatsInit::doPlaceholderInit();
+
+ User::resetIdByNameCache();
+
+ // Make sysop user
+ $user = static::getTestSysop()->getUser();
+
+ // Make 1 page with 1 revision
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ if ( $page->getId() == 0 ) {
+ $page->doEditContent(
+ new WikitextContent( 'UTContent' ),
+ 'UTPageSummary',
+ EDIT_NEW | EDIT_SUPPRESS_RC,
+ false,
+ $user
+ );
+ // an edit always attempt to purge backlink links such as history
+ // pages. That is unnecessary.
+ JobQueueGroup::singleton()->get( 'htmlCacheUpdate' )->delete();
+ // WikiPages::doEditUpdates randomly adds RC purges
+ JobQueueGroup::singleton()->get( 'recentChangesUpdate' )->delete();
+
+ // doEditContent() probably started the session via
+ // User::loadFromSession(). Close it now.
+ if ( session_id() !== '' ) {
+ session_write_close();
+ session_id( '' );
+ }
+ }
+ }
+
+ /**
+ * Restores MediaWiki to using the table set (table prefix) it was using before
+ * setupTestDB() was called. Useful if we need to perform database operations
+ * after the test run has finished (such as saving logs or profiling info).
+ *
+ * This is called by phpunit/bootstrap.php after the last test.
+ *
+ * @since 1.21
+ */
+ public static function teardownTestDB() {
+ global $wgJobClasses;
+
+ if ( !self::$dbSetup ) {
+ return;
+ }
+
+ Hooks::run( 'UnitTestsBeforeDatabaseTeardown' );
+
+ foreach ( $wgJobClasses as $type => $class ) {
+ // Delete any jobs under the clone DB (or old prefix in other stores)
+ JobQueueGroup::singleton()->get( $type )->delete();
+ }
+
+ // T219673: close any connections from code that failed to call reuseConnection()
+ // or is still holding onto a DBConnRef instance (e.g. in a singleton).
+ MediaWikiServices::getInstance()->getDBLoadBalancerFactory()->closeAll();
+ CloneDatabase::changePrefix( self::$oldTablePrefix );
+
+ self::$oldTablePrefix = false;
+ self::$dbSetup = false;
+ }
+
+ /**
+ * Setups a database with cloned tables using the given prefix.
+ *
+ * If reuseDB is true and certain conditions apply, it will just change the prefix.
+ * Otherwise, it will clone the tables and change the prefix.
+ *
+ * @param IMaintainableDatabase $db Database to use
+ * @param string|null $prefix Prefix to use for test tables. If not given, the prefix is determined
+ * automatically for $db.
+ * @return bool True if tables were cloned, false if only the prefix was changed
+ */
+ protected static function setupDatabaseWithTestPrefix(
+ IMaintainableDatabase $db,
+ $prefix = null
+ ) {
+ if ( $prefix === null ) {
+ $prefix = self::getTestPrefixFor( $db );
+ }
+
+ if ( ( $db->getType() == 'oracle' || !self::$useTemporaryTables ) && self::$reuseDB ) {
+ $db->tablePrefix( $prefix );
+ return false;
+ }
+
+ if ( !isset( $db->_originalTablePrefix ) ) {
+ $oldPrefix = $db->tablePrefix();
+
+ if ( $oldPrefix === $prefix ) {
+ // table already has the correct prefix, but presumably no cloned tables
+ $oldPrefix = self::$oldTablePrefix;
+ }
+
+ $db->tablePrefix( $oldPrefix );
+ $tablesCloned = self::listTables( $db );
+ $dbClone = new CloneDatabase( $db, $tablesCloned, $prefix, $oldPrefix );
+ $dbClone->useTemporaryTables( self::$useTemporaryTables );
+
+ $dbClone->cloneTableStructure();
+
+ $db->tablePrefix( $prefix );
+ $db->_originalTablePrefix = $oldPrefix;
+ }
+
+ return true;
+ }
+
+ /**
+ * Set up all test DBs
+ */
+ public function setupAllTestDBs() {
+ global $wgDBprefix;
+
+ self::$oldTablePrefix = $wgDBprefix;
+
+ $testPrefix = $this->dbPrefix();
+
+ // switch to a temporary clone of the database
+ self::setupTestDB( $this->db, $testPrefix );
+
+ if ( self::isUsingExternalStoreDB() ) {
+ self::setupExternalStoreTestDBs( $testPrefix );
+ }
+
+ // NOTE: Change the prefix in the LBFactory and $wgDBprefix, to prevent
+ // *any* database connections to operate on live data.
+ CloneDatabase::changePrefix( $testPrefix );
+ }
+
+ /**
+ * Creates an empty skeleton of the wiki database by cloning its structure
+ * to equivalent tables using the given $prefix. Then sets MediaWiki to
+ * use the new set of tables (aka schema) instead of the original set.
+ *
+ * This is used to generate a dummy table set, typically consisting of temporary
+ * tables, that will be used by tests instead of the original wiki database tables.
+ *
+ * @since 1.21
+ *
+ * @note the original table prefix is stored in self::$oldTablePrefix. This is used
+ * by teardownTestDB() to return the wiki to using the original table set.
+ *
+ * @note this method only works when first called. Subsequent calls have no effect,
+ * even if using different parameters.
+ *
+ * @param IMaintainableDatabase $db The database connection
+ * @param string $prefix The prefix to use for the new table set (aka schema).
+ *
+ * @throws MWException If the database table prefix is already $prefix
+ */
+ public static function setupTestDB( IMaintainableDatabase $db, $prefix ) {
+ if ( self::$dbSetup ) {
+ return;
+ }
+
+ if ( $db->tablePrefix() === $prefix ) {
+ throw new MWException(
+ 'Cannot run unit tests, the database prefix is already "' . $prefix . '"' );
+ }
+
+ // TODO: the below should be re-written as soon as LBFactory, LoadBalancer,
+ // and Database no longer use global state.
+
+ self::$dbSetup = true;
+
+ if ( !self::setupDatabaseWithTestPrefix( $db, $prefix ) ) {
+ return;
+ }
+
+ // Assuming this isn't needed for External Store database, and not sure if the procedure
+ // would be available there.
+ if ( $db->getType() == 'oracle' ) {
+ $db->query( 'BEGIN FILL_WIKI_INFO; END;', __METHOD__ );
+ }
+
+ Hooks::run( 'UnitTestsAfterDatabaseSetup', [ $db, $prefix ] );
+ }
+
+ /**
+ * Clones the External Store database(s) for testing
+ *
+ * @param string|null $testPrefix Prefix for test tables. Will be determined automatically
+ * if not given.
+ */
+ protected static function setupExternalStoreTestDBs( $testPrefix = null ) {
+ $connections = self::getExternalStoreDatabaseConnections();
+ foreach ( $connections as $dbw ) {
+ self::setupDatabaseWithTestPrefix( $dbw, $testPrefix );
+ }
+ }
+
+ /**
+ * Gets master database connections for all of the ExternalStoreDB
+ * stores configured in $wgDefaultExternalStore.
+ *
+ * @return Database[] Array of Database master connections
+ */
+ protected static function getExternalStoreDatabaseConnections() {
+ global $wgDefaultExternalStore;
+
+ /** @var ExternalStoreDB $externalStoreDB */
+ $externalStoreDB = ExternalStore::getStoreObject( 'DB' );
+ $defaultArray = (array)$wgDefaultExternalStore;
+ $dbws = [];
+ foreach ( $defaultArray as $url ) {
+ if ( strpos( $url, 'DB://' ) === 0 ) {
+ list( $proto, $cluster ) = explode( '://', $url, 2 );
+ // Avoid getMaster() because setupDatabaseWithTestPrefix()
+ // requires Database instead of plain DBConnRef/IDatabase
+ $dbws[] = $externalStoreDB->getMaster( $cluster );
+ }
+ }
+
+ return $dbws;
+ }
+
+ /**
+ * Check whether ExternalStoreDB is being used
+ *
+ * @return bool True if it's being used
+ */
+ protected static function isUsingExternalStoreDB() {
+ global $wgDefaultExternalStore;
+ if ( !$wgDefaultExternalStore ) {
+ return false;
+ }
+
+ $defaultArray = (array)$wgDefaultExternalStore;
+ foreach ( $defaultArray as $url ) {
+ if ( strpos( $url, 'DB://' ) === 0 ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @throws LogicException if the given database connection is not a set up to use
+ * mock tables.
+ *
+ * @since 1.31 this is no longer private.
+ */
+ protected function ensureMockDatabaseConnection( IDatabase $db ) {
+ if ( $db->tablePrefix() !== $this->dbPrefix() ) {
+ throw new LogicException(
+ 'Trying to delete mock tables, but table prefix does not indicate a mock database.'
+ );
+ }
+ }
+
+ private static $schemaOverrideDefaults = [
+ 'scripts' => [],
+ 'create' => [],
+ 'drop' => [],
+ 'alter' => [],
+ ];
+
+ /**
+ * Stub. If a test suite needs to test against a specific database schema, it should
+ * override this method and return the appropriate information from it.
+ *
+ * 'create', 'drop' and 'alter' in the returned array should list all the tables affected
+ * by the 'scripts', even if the test is only interested in a subset of them, otherwise
+ * the overrides may not be fully cleaned up, leading to errors later.
+ *
+ * @param IMaintainableDatabase $db The DB connection to use for the mock schema.
+ * May be used to check the current state of the schema, to determine what
+ * overrides are needed.
+ *
+ * @return array An associative array with the following fields:
+ * - 'scripts': any SQL scripts to run. If empty or not present, schema overrides are skipped.
+ * - 'create': A list of tables created (may or may not exist in the original schema).
+ * - 'drop': A list of tables dropped (expected to be present in the original schema).
+ * - 'alter': A list of tables altered (expected to be present in the original schema).
+ */
+ protected function getSchemaOverrides( IMaintainableDatabase $db ) {
+ return [];
+ }
+
+ /**
+ * Undoes the specified schema overrides..
+ * Called once per test class, just before addDataOnce().
+ *
+ * @param IMaintainableDatabase $db
+ * @param array $oldOverrides
+ */
+ private function undoSchemaOverrides( IMaintainableDatabase $db, $oldOverrides ) {
+ $this->ensureMockDatabaseConnection( $db );
+
+ $oldOverrides = $oldOverrides + self::$schemaOverrideDefaults;
+ $originalTables = $this->listOriginalTables( $db );
+
+ // Drop tables that need to be restored or removed.
+ $tablesToDrop = array_merge( $oldOverrides['create'], $oldOverrides['alter'] );
+
+ // Restore tables that have been dropped or created or altered,
+ // if they exist in the original schema.
+ $tablesToRestore = array_merge( $tablesToDrop, $oldOverrides['drop'] );
+ $tablesToRestore = array_intersect( $originalTables, $tablesToRestore );
+
+ if ( $tablesToDrop ) {
+ $this->dropMockTables( $db, $tablesToDrop );
+ }
+
+ if ( $tablesToRestore ) {
+ $this->recloneMockTables( $db, $tablesToRestore );
+
+ // Reset the restored tables, mainly for the side effect of
+ // re-calling $this->addCoreDBData() if necessary.
+ $this->resetDB( $db, $tablesToRestore );
+ }
+ }
+
+ /**
+ * Applies the schema overrides returned by getSchemaOverrides(),
+ * after undoing any previously applied schema overrides.
+ * Called once per test class, just before addDataOnce().
+ */
+ private function setUpSchema( IMaintainableDatabase $db ) {
+ // Undo any active overrides.
+ $oldOverrides = $db->_schemaOverrides ?? self::$schemaOverrideDefaults;
+
+ if ( $oldOverrides['alter'] || $oldOverrides['create'] || $oldOverrides['drop'] ) {
+ $this->undoSchemaOverrides( $db, $oldOverrides );
+ unset( $db->_schemaOverrides );
+ }
+
+ // Determine new overrides.
+ $overrides = $this->getSchemaOverrides( $db ) + self::$schemaOverrideDefaults;
+
+ $extraKeys = array_diff(
+ array_keys( $overrides ),
+ array_keys( self::$schemaOverrideDefaults )
+ );
+
+ if ( $extraKeys ) {
+ throw new InvalidArgumentException(
+ 'Schema override contains extra keys: ' . var_export( $extraKeys, true )
+ );
+ }
+
+ if ( !$overrides['scripts'] ) {
+ // no scripts to run
+ return;
+ }
+
+ if ( !$overrides['create'] && !$overrides['drop'] && !$overrides['alter'] ) {
+ throw new InvalidArgumentException(
+ 'Schema override scripts given, but no tables are declared to be '
+ . 'created, dropped or altered.'
+ );
+ }
+
+ $this->ensureMockDatabaseConnection( $db );
+
+ // Drop the tables that will be created by the schema scripts.
+ $originalTables = $this->listOriginalTables( $db );
+ $tablesToDrop = array_intersect( $originalTables, $overrides['create'] );
+
+ if ( $tablesToDrop ) {
+ $this->dropMockTables( $db, $tablesToDrop );
+ }
+
+ // Run schema override scripts.
+ foreach ( $overrides['scripts'] as $script ) {
+ $db->sourceFile(
+ $script,
+ null,
+ null,
+ __METHOD__,
+ function ( $cmd ) {
+ return $this->mungeSchemaUpdateQuery( $cmd );
+ }
+ );
+ }
+
+ $db->_schemaOverrides = $overrides;
+ }
+
+ private function mungeSchemaUpdateQuery( $cmd ) {
+ return self::$useTemporaryTables
+ ? preg_replace( '/\bCREATE\s+TABLE\b/i', 'CREATE TEMPORARY TABLE', $cmd )
+ : $cmd;
+ }
+
+ /**
+ * Drops the given mock tables.
+ *
+ * @param IMaintainableDatabase $db
+ * @param array $tables
+ */
+ private function dropMockTables( IMaintainableDatabase $db, array $tables ) {
+ $this->ensureMockDatabaseConnection( $db );
+
+ foreach ( $tables as $tbl ) {
+ $tbl = $db->tableName( $tbl );
+ $db->query( "DROP TABLE IF EXISTS $tbl", __METHOD__ );
+ }
+ }
+
+ /**
+ * Lists all tables in the live database schema, without a prefix.
+ *
+ * @param IMaintainableDatabase $db
+ * @return array
+ */
+ private function listOriginalTables( IMaintainableDatabase $db ) {
+ if ( !isset( $db->_originalTablePrefix ) ) {
+ throw new LogicException( 'No original table prefix know, cannot list tables!' );
+ }
+
+ $originalTables = $db->listTables( $db->_originalTablePrefix, __METHOD__ );
+
+ $unittestPrefixRegex = '/^' . preg_quote( $this->dbPrefix(), '/' ) . '/';
+ $originalPrefixRegex = '/^' . preg_quote( $db->_originalTablePrefix, '/' ) . '/';
+
+ $originalTables = array_filter(
+ $originalTables,
+ function ( $pt ) use ( $unittestPrefixRegex ) {
+ return !preg_match( $unittestPrefixRegex, $pt );
+ }
+ );
+
+ $originalTables = array_map(
+ function ( $pt ) use ( $originalPrefixRegex ) {
+ return preg_replace( $originalPrefixRegex, '', $pt );
+ },
+ $originalTables
+ );
+
+ return array_unique( $originalTables );
+ }
+
+ /**
+ * Re-clones the given mock tables to restore them based on the live database schema.
+ * The tables listed in $tables are expected to currently not exist, so dropMockTables()
+ * should be called first.
+ *
+ * @param IMaintainableDatabase $db
+ * @param array $tables
+ */
+ private function recloneMockTables( IMaintainableDatabase $db, array $tables ) {
+ $this->ensureMockDatabaseConnection( $db );
+
+ if ( !isset( $db->_originalTablePrefix ) ) {
+ throw new LogicException( 'No original table prefix know, cannot restore tables!' );
+ }
+
+ $originalTables = $this->listOriginalTables( $db );
+ $tables = array_intersect( $tables, $originalTables );
+
+ $dbClone = new CloneDatabase( $db, $tables, $db->tablePrefix(), $db->_originalTablePrefix );
+ $dbClone->useTemporaryTables( self::$useTemporaryTables );
+
+ $dbClone->cloneTableStructure();
+ }
+
+ /**
+ * Empty all tables so they can be repopulated for tests
+ *
+ * @param Database $db|null Database to reset
+ * @param array $tablesUsed Tables to reset
+ */
+ private function resetDB( $db, $tablesUsed ) {
+ if ( $db ) {
+ $userTables = [ 'user', 'user_groups', 'user_properties', 'actor' ];
+ $pageTables = [
+ 'page', 'revision', 'ip_changes', 'revision_comment_temp', 'comment', 'archive',
+ 'revision_actor_temp', 'slots', 'content', 'content_models', 'slot_roles',
+ ];
+ $coreDBDataTables = array_merge( $userTables, $pageTables );
+
+ // If any of the user or page tables were marked as used, we should clear all of them.
+ if ( array_intersect( $tablesUsed, $userTables ) ) {
+ $tablesUsed = array_unique( array_merge( $tablesUsed, $userTables ) );
+ TestUserRegistry::clear();
+
+ // Reset $wgUser, which is probably 127.0.0.1, as its loaded data is probably not valid
+ // @todo Should we start setting $wgUser to something nondeterministic
+ // to encourage tests to be updated to not depend on it?
+ global $wgUser;
+ $wgUser->clearInstanceCache( $wgUser->mFrom );
+ }
+ if ( array_intersect( $tablesUsed, $pageTables ) ) {
+ $tablesUsed = array_unique( array_merge( $tablesUsed, $pageTables ) );
+ }
+
+ // Postgres, Oracle, and MSSQL all use mwuser/pagecontent
+ // instead of user/text. But Postgres does not remap the
+ // table name in tableExists(), so we mark the real table
+ // names as being used.
+ if ( $db->getType() === 'postgres' ) {
+ if ( in_array( 'user', $tablesUsed ) ) {
+ $tablesUsed[] = 'mwuser';
+ }
+ if ( in_array( 'text', $tablesUsed ) ) {
+ $tablesUsed[] = 'pagecontent';
+ }
+ }
+
+ foreach ( $tablesUsed as $tbl ) {
+ $this->truncateTable( $tbl, $db );
+ }
+
+ if ( array_intersect( $tablesUsed, $coreDBDataTables ) ) {
+ // Reset services that may contain information relating to the truncated tables
+ $this->overrideMwServices();
+ // Re-add core DB data that was deleted
+ $this->addCoreDBData();
+ }
+ }
+ }
+
+ /**
+ * Empties the given table and resets any auto-increment counters.
+ * Will also purge caches associated with some well known tables.
+ * If the table is not know, this method just returns.
+ *
+ * @param string $tableName
+ * @param IDatabase|null $db
+ */
+ protected function truncateTable( $tableName, IDatabase $db = null ) {
+ if ( !$db ) {
+ $db = $this->db;
+ }
+
+ if ( !$db->tableExists( $tableName ) ) {
+ return;
+ }
+
+ $truncate = in_array( $db->getType(), [ 'oracle', 'mysql' ] );
+
+ if ( $truncate ) {
+ $db->query( 'TRUNCATE TABLE ' . $db->tableName( $tableName ), __METHOD__ );
+ } else {
+ $db->delete( $tableName, '*', __METHOD__ );
+ }
+
+ if ( $db instanceof DatabasePostgres || $db instanceof DatabaseSqlite ) {
+ // Reset the table's sequence too.
+ $db->resetSequenceForTable( $tableName, __METHOD__ );
+ }
+
+ // re-initialize site_stats table
+ if ( $tableName === 'site_stats' ) {
+ SiteStatsInit::doPlaceholderInit();
+ }
+ }
+
+ private static function unprefixTable( &$tableName, $ind, $prefix ) {
+ $tableName = substr( $tableName, strlen( $prefix ) );
+ }
+
+ private static function isNotUnittest( $table ) {
+ return strpos( $table, self::DB_PREFIX ) !== 0;
+ }
+
+ /**
+ * @since 1.18
+ *
+ * @param IMaintainableDatabase $db
+ *
+ * @return array
+ */
+ public static function listTables( IMaintainableDatabase $db ) {
+ $prefix = $db->tablePrefix();
+ $tables = $db->listTables( $prefix, __METHOD__ );
+
+ if ( $db->getType() === 'mysql' ) {
+ static $viewListCache = null;
+ if ( $viewListCache === null ) {
+ $viewListCache = $db->listViews( null, __METHOD__ );
+ }
+ // T45571: cannot clone VIEWs under MySQL
+ $tables = array_diff( $tables, $viewListCache );
+ }
+ array_walk( $tables, [ __CLASS__, 'unprefixTable' ], $prefix );
+
+ // Don't duplicate test tables from the previous fataled run
+ $tables = array_filter( $tables, [ __CLASS__, 'isNotUnittest' ] );
+
+ if ( $db->getType() == 'sqlite' ) {
+ $tables = array_flip( $tables );
+ // these are subtables of searchindex and don't need to be duped/dropped separately
+ unset( $tables['searchindex_content'] );
+ unset( $tables['searchindex_segdir'] );
+ unset( $tables['searchindex_segments'] );
+ $tables = array_flip( $tables );
+ }
+
+ return $tables;
+ }
+
+ /**
+ * Copy test data from one database connection to another.
+ *
+ * This should only be used for small data sets.
+ *
+ * @param IDatabase $source
+ * @param IDatabase $target
+ */
+ public function copyTestData( IDatabase $source, IDatabase $target ) {
+ if ( $this->db->getType() === 'sqlite' ) {
+ // SQLite uses a non-temporary copy of the searchindex table for testing,
+ // which gets deleted and re-created when setting up the secondary connection,
+ // causing "Error 17" when trying to copy the data. See T191863#4130112.
+ throw new RuntimeException(
+ 'Setting up a secondary database connection with test data is currently not'
+ . 'with SQLite. You may want to use markTestSkippedIfDbType() to bypass this issue.'
+ );
+ }
+
+ $tables = self::listOriginalTables( $source );
+
+ foreach ( $tables as $table ) {
+ $res = $source->select( $table, '*', [], __METHOD__ );
+ $allRows = [];
+
+ foreach ( $res as $row ) {
+ $allRows[] = (array)$row;
+ }
+
+ $target->insert( $table, $allRows, __METHOD__, [ 'IGNORE' ] );
+ }
+ }
+
+ /**
+ * @throws MWException
+ * @since 1.18
+ */
+ protected function checkDbIsSupported() {
+ if ( !in_array( $this->db->getType(), $this->supportedDBs ) ) {
+ throw new MWException( $this->db->getType() . " is not currently supported for unit testing." );
+ }
+ }
+
+ /**
+ * @since 1.18
+ * @param string $offset
+ * @return mixed
+ */
+ public function getCliArg( $offset ) {
+ return $this->cliArgs[$offset] ?? null;
+ }
+
+ /**
+ * @since 1.18
+ * @param string $offset
+ * @param mixed $value
+ */
+ public function setCliArg( $offset, $value ) {
+ $this->cliArgs[$offset] = $value;
+ }
+
+ /**
+ * Don't throw a warning if $function is deprecated and called later
+ *
+ * @since 1.19
+ *
+ * @param string $function
+ */
+ public function hideDeprecated( $function ) {
+ Wikimedia\suppressWarnings();
+ wfDeprecated( $function );
+ Wikimedia\restoreWarnings();
+ }
+
+ /**
+ * Asserts that the given database query yields the rows given by $expectedRows.
+ * The expected rows should be given as indexed (not associative) arrays, with
+ * the values given in the order of the columns in the $fields parameter.
+ * Note that the rows are sorted by the columns given in $fields.
+ *
+ * @since 1.20
+ *
+ * @param string|array $table The table(s) to query
+ * @param string|array $fields The columns to include in the result (and to sort by)
+ * @param string|array $condition "where" condition(s)
+ * @param array $expectedRows An array of arrays giving the expected rows.
+ * @param array $options Options for the query
+ * @param array $join_conds Join conditions for the query
+ *
+ * @throws MWException If this test cases's needsDB() method doesn't return true.
+ * Test cases can use "@group Database" to enable database test support,
+ * or list the tables under testing in $this->tablesUsed, or override the
+ * needsDB() method.
+ */
+ protected function assertSelect(
+ $table, $fields, $condition, array $expectedRows, array $options = [], array $join_conds = []
+ ) {
+ if ( !$this->needsDB() ) {
+ throw new MWException( 'When testing database state, the test cases\'s needDB()' .
+ ' method should return true. Use @group Database or $this->tablesUsed.' );
+ }
+
+ $db = wfGetDB( DB_REPLICA );
+
+ $res = $db->select(
+ $table,
+ $fields,
+ $condition,
+ wfGetCaller(),
+ $options + [ 'ORDER BY' => $fields ],
+ $join_conds
+ );
+ $this->assertNotEmpty( $res, "query failed: " . $db->lastError() );
+
+ $i = 0;
+
+ foreach ( $expectedRows as $expected ) {
+ $r = $res->fetchRow();
+ self::stripStringKeys( $r );
+
+ $i += 1;
+ $this->assertNotEmpty( $r, "row #$i missing" );
+
+ $this->assertEquals( $expected, $r, "row #$i mismatches" );
+ }
+
+ $r = $res->fetchRow();
+ self::stripStringKeys( $r );
+
+ $this->assertFalse( $r, "found extra row (after #$i)" );
+ }
+
+ /**
+ * Utility method taking an array of elements and wrapping
+ * each element in its own array. Useful for data providers
+ * that only return a single argument.
+ *
+ * @since 1.20
+ *
+ * @param array $elements
+ *
+ * @return array
+ */
+ protected function arrayWrap( array $elements ) {
+ return array_map(
+ function ( $element ) {
+ return [ $element ];
+ },
+ $elements
+ );
+ }
+
+ /**
+ * Assert that two arrays are equal. By default this means that both arrays need to hold
+ * the same set of values. Using additional arguments, order and associated key can also
+ * be set as relevant.
+ *
+ * @since 1.20
+ *
+ * @param array $expected
+ * @param array $actual
+ * @param bool $ordered If the order of the values should match
+ * @param bool $named If the keys should match
+ */
+ protected function assertArrayEquals( array $expected, array $actual,
+ $ordered = false, $named = false
+ ) {
+ if ( !$ordered ) {
+ $this->objectAssociativeSort( $expected );
+ $this->objectAssociativeSort( $actual );
+ }
+
+ if ( !$named ) {
+ $expected = array_values( $expected );
+ $actual = array_values( $actual );
+ }
+
+ call_user_func_array(
+ [ $this, 'assertEquals' ],
+ array_merge( [ $expected, $actual ], array_slice( func_get_args(), 4 ) )
+ );
+ }
+
+ /**
+ * Put each HTML element on its own line and then equals() the results
+ *
+ * Use for nicely formatting of PHPUnit diff output when comparing very
+ * simple HTML
+ *
+ * @since 1.20
+ *
+ * @param string $expected HTML on oneline
+ * @param string $actual HTML on oneline
+ * @param string $msg Optional message
+ */
+ protected function assertHTMLEquals( $expected, $actual, $msg = '' ) {
+ $expected = str_replace( '>', ">\n", $expected );
+ $actual = str_replace( '>', ">\n", $actual );
+
+ $this->assertEquals( $expected, $actual, $msg );
+ }
+
+ /**
+ * Does an associative sort that works for objects.
+ *
+ * @since 1.20
+ *
+ * @param array &$array
+ */
+ protected function objectAssociativeSort( array &$array ) {
+ uasort(
+ $array,
+ function ( $a, $b ) {
+ return serialize( $a ) <=> serialize( $b );
+ }
+ );
+ }
+
+ /**
+ * Utility function for eliminating all string keys from an array.
+ * Useful to turn a database result row as returned by fetchRow() into
+ * a pure indexed array.
+ *
+ * @since 1.20
+ *
+ * @param mixed &$r The array to remove string keys from.
+ */
+ protected static function stripStringKeys( &$r ) {
+ if ( !is_array( $r ) ) {
+ return;
+ }
+
+ foreach ( $r as $k => $v ) {
+ if ( is_string( $k ) ) {
+ unset( $r[$k] );
+ }
+ }
+ }
+
+ /**
+ * Asserts that the provided variable is of the specified
+ * internal type or equals the $value argument. This is useful
+ * for testing return types of functions that return a certain
+ * type or *value* when not set or on error.
+ *
+ * @since 1.20
+ *
+ * @param string $type
+ * @param mixed $actual
+ * @param mixed $value
+ * @param string $message
+ */
+ protected function assertTypeOrValue( $type, $actual, $value = false, $message = '' ) {
+ if ( $actual === $value ) {
+ $this->assertTrue( true, $message );
+ } else {
+ $this->assertType( $type, $actual, $message );
+ }
+ }
+
+ /**
+ * Asserts the type of the provided value. This can be either
+ * in internal type such as boolean or integer, or a class or
+ * interface the value extends or implements.
+ *
+ * @since 1.20
+ *
+ * @param string $type
+ * @param mixed $actual
+ * @param string $message
+ */
+ protected function assertType( $type, $actual, $message = '' ) {
+ if ( class_exists( $type ) || interface_exists( $type ) ) {
+ $this->assertInstanceOf( $type, $actual, $message );
+ } else {
+ $this->assertInternalType( $type, $actual, $message );
+ }
+ }
+
+ /**
+ * Returns true if the given namespace defaults to Wikitext
+ * according to $wgNamespaceContentModels
+ *
+ * @param int $ns The namespace ID to check
+ *
+ * @return bool
+ * @since 1.21
+ */
+ protected function isWikitextNS( $ns ) {
+ global $wgNamespaceContentModels;
+
+ if ( isset( $wgNamespaceContentModels[$ns] ) ) {
+ return $wgNamespaceContentModels[$ns] === CONTENT_MODEL_WIKITEXT;
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns the ID of a namespace that defaults to Wikitext.
+ *
+ * @throws MWException If there is none.
+ * @return int The ID of the wikitext Namespace
+ * @since 1.21
+ */
+ protected function getDefaultWikitextNS() {
+ global $wgNamespaceContentModels;
+
+ static $wikitextNS = null; // this is not going to change
+ if ( $wikitextNS !== null ) {
+ return $wikitextNS;
+ }
+
+ // quickly short out on most common case:
+ if ( !isset( $wgNamespaceContentModels[NS_MAIN] ) ) {
+ return NS_MAIN;
+ }
+
+ // NOTE: prefer content namespaces
+ $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
+ $namespaces = array_unique( array_merge(
+ $nsInfo->getContentNamespaces(),
+ [ NS_MAIN, NS_HELP, NS_PROJECT ], // prefer these
+ $nsInfo->getValidNamespaces()
+ ) );
+
+ $namespaces = array_diff( $namespaces, [
+ NS_FILE, NS_CATEGORY, NS_MEDIAWIKI, NS_USER // don't mess with magic namespaces
+ ] );
+
+ $talk = array_filter( $namespaces, function ( $ns ) use ( $nsInfo ) {
+ return $nsInfo->isTalk( $ns );
+ } );
+
+ // prefer non-talk pages
+ $namespaces = array_diff( $namespaces, $talk );
+ $namespaces = array_merge( $namespaces, $talk );
+
+ // check default content model of each namespace
+ foreach ( $namespaces as $ns ) {
+ if ( !isset( $wgNamespaceContentModels[$ns] ) ||
+ $wgNamespaceContentModels[$ns] === CONTENT_MODEL_WIKITEXT
+ ) {
+ $wikitextNS = $ns;
+
+ return $wikitextNS;
+ }
+ }
+
+ // give up
+ // @todo Inside a test, we could skip the test as incomplete.
+ // But frequently, this is used in fixture setup.
+ throw new MWException( "No namespace defaults to wikitext!" );
+ }
+
+ /**
+ * Check, if $wgDiff3 is set and ready to merge
+ * Will mark the calling test as skipped, if not ready
+ *
+ * @since 1.21
+ */
+ protected function markTestSkippedIfNoDiff3() {
+ global $wgDiff3;
+
+ # This check may also protect against code injection in
+ # case of broken installations.
+ Wikimedia\suppressWarnings();
+ $haveDiff3 = $wgDiff3 && file_exists( $wgDiff3 );
+ Wikimedia\restoreWarnings();
+
+ if ( !$haveDiff3 ) {
+ $this->markTestSkipped( "Skip test, since diff3 is not configured" );
+ }
+ }
+
+ /**
+ * Check if $extName is a loaded PHP extension, will skip the
+ * test whenever it is not loaded.
+ *
+ * @since 1.21
+ * @param string $extName
+ * @return bool
+ */
+ protected function checkPHPExtension( $extName ) {
+ $loaded = extension_loaded( $extName );
+ if ( !$loaded ) {
+ $this->markTestSkipped( "PHP extension '$extName' is not loaded, skipping." );
+ }
+
+ return $loaded;
+ }
+
+ /**
+ * Skip the test if using the specified database type
+ *
+ * @param string $type Database type
+ * @since 1.32
+ */
+ protected function markTestSkippedIfDbType( $type ) {
+ if ( $this->db->getType() === $type ) {
+ $this->markTestSkipped( "The $type database type isn't supported for this test" );
+ }
+ }
+
+ /**
+ * Used as a marker to prevent wfResetOutputBuffers from breaking PHPUnit.
+ * @param string $buffer
+ * @return string
+ */
+ public static function wfResetOutputBuffersBarrier( $buffer ) {
+ return $buffer;
+ }
+
+ /**
+ * Create a temporary hook handler which will be reset by tearDown.
+ * This replaces other handlers for the same hook.
+ * @param string $hookName Hook name
+ * @param mixed $handler Value suitable for a hook handler
+ * @since 1.28
+ */
+ protected function setTemporaryHook( $hookName, $handler ) {
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [ $hookName => [ $handler ] ] );
+ }
+
+ /**
+ * Check whether file contains given data.
+ * @param string $fileName
+ * @param string $actualData
+ * @param bool $createIfMissing If true, and file does not exist, create it with given data
+ * and skip the test.
+ * @param string $msg
+ * @since 1.30
+ */
+ protected function assertFileContains(
+ $fileName,
+ $actualData,
+ $createIfMissing = false,
+ $msg = ''
+ ) {
+ if ( $createIfMissing ) {
+ if ( !file_exists( $fileName ) ) {
+ file_put_contents( $fileName, $actualData );
+ $this->markTestSkipped( "Data file $fileName does not exist" );
+ }
+ } else {
+ self::assertFileExists( $fileName );
+ }
+ self::assertEquals( file_get_contents( $fileName ), $actualData, $msg );
+ }
+
+ /**
+ * Edits or creates a page/revision
+ * @param string $pageName Page title
+ * @param string $text Content of the page
+ * @param string $summary Optional summary string for the revision
+ * @param int $defaultNs Optional namespace id
+ * @param User|null $user If null, static::getTestSysop()->getUser() is used.
+ * @return Status Object as returned by WikiPage::doEditContent()
+ * @throws MWException If this test cases's needsDB() method doesn't return true.
+ * Test cases can use "@group Database" to enable database test support,
+ * or list the tables under testing in $this->tablesUsed, or override the
+ * needsDB() method.
+ */
+ protected function editPage(
+ $pageName,
+ $text,
+ $summary = '',
+ $defaultNs = NS_MAIN,
+ User $user = null
+ ) {
+ if ( !$this->needsDB() ) {
+ throw new MWException( 'When testing which pages, the test cases\'s needsDB()' .
+ ' method should return true. Use @group Database or $this->tablesUsed.' );
+ }
+
+ $title = Title::newFromText( $pageName, $defaultNs );
+ $page = WikiPage::factory( $title );
+
+ return $page->doEditContent(
+ ContentHandler::makeContent( $text, $title ),
+ $summary,
+ 0,
+ false,
+ $user
+ );
+ }
+
+ /**
+ * Revision-deletes a revision.
+ *
+ * @param Revision|int $rev Revision to delete
+ * @param array $value Keys are Revision::DELETED_* flags. Values are 1 to set the bit, 0 to
+ * clear, -1 to leave alone. (All other values also clear the bit.)
+ * @param string $comment Deletion comment
+ */
+ protected function revisionDelete(
+ $rev, array $value = [ Revision::DELETED_TEXT => 1 ], $comment = ''
+ ) {
+ if ( is_int( $rev ) ) {
+ $rev = Revision::newFromId( $rev );
+ }
+ RevisionDeleter::createList(
+ 'revision', RequestContext::getMain(), $rev->getTitle(), [ $rev->getId() ]
+ )->setVisibility( [
+ 'value' => $value,
+ 'comment' => $comment,
+ ] );
+ }
+
+ /**
+ * Returns a PHPUnit constraint that matches anything other than a fixed set of values. This can
+ * be used to whitelist values, e.g.
+ * $mock->expects( $this->never() )->method( $this->anythingBut( 'foo', 'bar' ) );
+ * which will throw if any unexpected method is called.
+ *
+ * @param mixed ...$values Values that are not matched
+ */
+ protected function anythingBut( ...$values ) {
+ return $this->logicalNot( $this->logicalOr(
+ ...array_map( [ $this, 'matches' ], $values )
+ ) );
+ }
+}
+
+class_alias( 'MediaWikiIntegrationTestCase', 'MediaWikiTestCase' );
+++ /dev/null
-<?php
-
-use MediaWiki\Logger\LegacySpi;
-use MediaWiki\Logger\LoggerFactory;
-use MediaWiki\Logger\MonologSpi;
-use MediaWiki\Logger\LogCapturingSpi;
-use MediaWiki\MediaWikiServices;
-use Psr\Log\LoggerInterface;
-use Wikimedia\Rdbms\IDatabase;
-use Wikimedia\Rdbms\IMaintainableDatabase;
-use Wikimedia\Rdbms\Database;
-use Wikimedia\TestingAccessWrapper;
-
-/**
- * @since 1.18
- */
-abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
-
- use MediaWikiCoversValidator;
- use PHPUnit4And6Compat;
-
- /**
- * The original service locator. This is overridden during setUp().
- *
- * @var MediaWikiServices|null
- */
- private static $originalServices;
-
- /**
- * The local service locator, created during setUp().
- * @var MediaWikiServices
- */
- private $localServices;
-
- /**
- * $called tracks whether the setUp and tearDown method has been called.
- * class extending MediaWikiTestCase usually override setUp and tearDown
- * but forget to call the parent.
- *
- * The array format takes a method name as key and anything as a value.
- * By asserting the key exist, we know the child class has called the
- * parent.
- *
- * This property must be private, we do not want child to override it,
- * they should call the appropriate parent method instead.
- */
- private $called = [];
-
- /**
- * @var TestUser[]
- * @since 1.20
- */
- public static $users;
-
- /**
- * Primary database
- *
- * @var Database
- * @since 1.18
- */
- protected $db;
-
- /**
- * @var array
- * @since 1.19
- */
- protected $tablesUsed = []; // tables with data
-
- private static $useTemporaryTables = true;
- private static $reuseDB = false;
- private static $dbSetup = false;
- private static $oldTablePrefix = '';
-
- /**
- * Original value of PHP's error_reporting setting.
- *
- * @var int
- */
- private $phpErrorLevel;
-
- /**
- * Holds the paths of temporary files/directories created through getNewTempFile,
- * and getNewTempDirectory
- *
- * @var array
- */
- private $tmpFiles = [];
-
- /**
- * Holds original values of MediaWiki configuration settings
- * to be restored in tearDown().
- * See also setMwGlobals().
- * @var array
- */
- private $mwGlobals = [];
-
- /**
- * Holds list of MediaWiki configuration settings to be unset in tearDown().
- * See also setMwGlobals().
- * @var array
- */
- 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[]
- */
- private $loggers = [];
-
- /**
- * The CLI arguments passed through from phpunit.php
- * @var array
- */
- private $cliArgs = [];
-
- /**
- * Holds a list of services that were overridden with setService(). Used for printing an error
- * if overrideMwServices() overrides a service that was previously set.
- * @var string[]
- */
- private $overriddenServices = [];
-
- /**
- * Table name prefixes. Oracle likes it shorter.
- */
- const DB_PREFIX = 'unittest_';
- const ORA_DB_PREFIX = 'ut_';
-
- /**
- * @var array
- * @since 1.18
- */
- protected $supportedDBs = [
- 'mysql',
- 'sqlite',
- 'postgres',
- 'oracle'
- ];
-
- public function __construct( $name = null, array $data = [], $dataName = '' ) {
- parent::__construct( $name, $data, $dataName );
-
- $this->backupGlobals = false;
- $this->backupStaticAttributes = false;
- }
-
- public function __destruct() {
- // Complain if self::setUp() was called, but not self::tearDown()
- // $this->called['setUp'] will be checked by self::testMediaWikiTestCaseParentSetupCalled()
- if ( isset( $this->called['setUp'] ) && !isset( $this->called['tearDown'] ) ) {
- throw new MWException( static::class . "::tearDown() must call parent::tearDown()" );
- }
- }
-
- public static function setUpBeforeClass() {
- parent::setUpBeforeClass();
-
- // Get the original service locator
- if ( !self::$originalServices ) {
- self::$originalServices = MediaWikiServices::getInstance();
- }
- }
-
- /**
- * Convenience method for getting an immutable test user
- *
- * @since 1.28
- *
- * @param string|string[] $groups Groups the test user should be in.
- * @return TestUser
- */
- public static function getTestUser( $groups = [] ) {
- return TestUserRegistry::getImmutableTestUser( $groups );
- }
-
- /**
- * Convenience method for getting a mutable test user
- *
- * @since 1.28
- *
- * @param string|string[] $groups Groups the test user should be added in.
- * @return TestUser
- */
- public static function getMutableTestUser( $groups = [] ) {
- return TestUserRegistry::getMutableTestUser( __CLASS__, $groups );
- }
-
- /**
- * Convenience method for getting an immutable admin test user
- *
- * @since 1.28
- *
- * @param string[] $groups Groups the test user should be added to.
- * @return TestUser
- */
- public static function getTestSysop() {
- return self::getTestUser( [ 'sysop', 'bureaucrat' ] );
- }
-
- /**
- * Returns a WikiPage representing an existing page.
- *
- * @since 1.32
- *
- * @param Title|string|null $title
- * @return WikiPage
- * @throws MWException If this test cases's needsDB() method doesn't return true.
- * Test cases can use "@group Database" to enable database test support,
- * or list the tables under testing in $this->tablesUsed, or override the
- * needsDB() method.
- */
- protected function getExistingTestPage( $title = null ) {
- if ( !$this->needsDB() ) {
- throw new MWException( 'When testing which pages, the test cases\'s needsDB()' .
- ' method should return true. Use @group Database or $this->tablesUsed.' );
- }
-
- $title = ( $title === null ) ? 'UTPage' : $title;
- $title = is_string( $title ) ? Title::newFromText( $title ) : $title;
- $page = WikiPage::factory( $title );
-
- if ( !$page->exists() ) {
- $user = self::getTestSysop()->getUser();
- $page->doEditContent(
- new WikitextContent( 'UTContent' ),
- 'UTPageSummary',
- EDIT_NEW | EDIT_SUPPRESS_RC,
- false,
- $user
- );
- }
-
- return $page;
- }
-
- /**
- * Returns a WikiPage representing a non-existing page.
- *
- * @since 1.32
- *
- * @param Title|string|null $title
- * @return WikiPage
- * @throws MWException If this test cases's needsDB() method doesn't return true.
- * Test cases can use "@group Database" to enable database test support,
- * or list the tables under testing in $this->tablesUsed, or override the
- * needsDB() method.
- */
- protected function getNonexistingTestPage( $title = null ) {
- if ( !$this->needsDB() ) {
- throw new MWException( 'When testing which pages, the test cases\'s needsDB()' .
- ' method should return true. Use @group Database or $this->tablesUsed.' );
- }
-
- $title = ( $title === null ) ? 'UTPage-' . rand( 0, 100000 ) : $title;
- $title = is_string( $title ) ? Title::newFromText( $title ) : $title;
- $page = WikiPage::factory( $title );
-
- if ( $page->exists() ) {
- $page->doDeleteArticle( 'Testing' );
- }
-
- return $page;
- }
-
- /**
- * @deprecated since 1.32
- */
- public static function prepareServices( Config $bootstrapConfig ) {
- }
-
- /**
- * Create a config suitable for testing, based on a base config, default overrides,
- * and custom overrides.
- *
- * @param Config|null $baseConfig
- * @param Config|null $customOverrides
- *
- * @return Config
- */
- private static function makeTestConfig(
- Config $baseConfig = null,
- Config $customOverrides = null
- ) {
- $defaultOverrides = new HashConfig();
-
- if ( !$baseConfig ) {
- $baseConfig = self::$originalServices->getBootstrapConfig();
- }
-
- /* Some functions require some kind of caching, and will end up using the db,
- * which we can't allow, as that would open a new connection for mysql.
- * Replace with a HashBag. They would not be going to persist anyway.
- */
- $hashCache = [ 'class' => HashBagOStuff::class, 'reportDupes' => false ];
- $objectCaches = [
- CACHE_DB => $hashCache,
- CACHE_ACCEL => $hashCache,
- CACHE_MEMCACHED => $hashCache,
- 'apc' => $hashCache,
- 'apcu' => $hashCache,
- 'wincache' => $hashCache,
- ] + $baseConfig->get( 'ObjectCaches' );
-
- $defaultOverrides->set( 'ObjectCaches', $objectCaches );
- $defaultOverrides->set( 'MainCacheType', CACHE_NONE );
- $defaultOverrides->set( 'JobTypeConf', [ 'default' => [ 'class' => JobQueueMemory::class ] ] );
-
- // Use a fast hash algorithm to hash passwords.
- $defaultOverrides->set( 'PasswordDefault', 'A' );
-
- $testConfig = $customOverrides
- ? new MultiConfig( [ $customOverrides, $defaultOverrides, $baseConfig ] )
- : new MultiConfig( [ $defaultOverrides, $baseConfig ] );
-
- return $testConfig;
- }
-
- /**
- * @param ConfigFactory $oldFactory
- * @param Config[] $configurations
- *
- * @return Closure
- */
- private static function makeTestConfigFactoryInstantiator(
- ConfigFactory $oldFactory,
- array $configurations
- ) {
- return function ( MediaWikiServices $services ) use ( $oldFactory, $configurations ) {
- $factory = new ConfigFactory();
-
- // clone configurations from $oldFactory that are not overwritten by $configurations
- $namesToClone = array_diff(
- $oldFactory->getConfigNames(),
- array_keys( $configurations )
- );
-
- foreach ( $namesToClone as $name ) {
- $factory->register( $name, $oldFactory->makeConfig( $name ) );
- }
-
- foreach ( $configurations as $name => $config ) {
- $factory->register( $name, $config );
- }
-
- return $factory;
- };
- }
-
- /**
- * Resets some non-service singleton instances and other static caches. It's not necessary to
- * reset services here.
- */
- public static function resetNonServiceCaches() {
- global $wgRequest, $wgJobClasses;
-
- User::resetGetDefaultOptionsForTestsOnly();
- foreach ( $wgJobClasses as $type => $class ) {
- JobQueueGroup::singleton()->get( $type )->delete();
- }
- JobQueueGroup::destroySingletons();
-
- ObjectCache::clear();
- FileBackendGroup::destroySingleton();
- DeferredUpdates::clearPendingUpdates();
-
- // TODO: move global state into MediaWikiServices
- RequestContext::resetMain();
- if ( session_id() !== '' ) {
- session_write_close();
- session_id( '' );
- }
-
- $wgRequest = new FauxRequest();
- MediaWiki\Session\SessionManager::resetCache();
- }
-
- public function run( PHPUnit_Framework_TestResult $result = null ) {
- if ( $result instanceof MediaWikiTestResult ) {
- $this->cliArgs = $result->getMediaWikiCliArgs();
- }
- $this->overrideMwServices();
-
- if ( $this->needsDB() && !$this->isTestInDatabaseGroup() ) {
- throw new Exception(
- get_class( $this ) . ' apparently needsDB but is not in the Database group'
- );
- }
-
- $needsResetDB = false;
- if ( !self::$dbSetup || $this->needsDB() ) {
- // set up a DB connection for this test to use
-
- self::$useTemporaryTables = !$this->getCliArg( 'use-normal-tables' );
- self::$reuseDB = $this->getCliArg( 'reuse-db' );
-
- $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
- $this->db = $lb->getConnection( DB_MASTER );
-
- $this->checkDbIsSupported();
-
- if ( !self::$dbSetup ) {
- $this->setupAllTestDBs();
- $this->addCoreDBData();
- }
-
- // TODO: the DB setup should be done in setUpBeforeClass(), so the test DB
- // is available in subclass's setUpBeforeClass() and setUp() methods.
- // This would also remove the need for the HACK that is oncePerClass().
- if ( $this->oncePerClass() ) {
- $this->setUpSchema( $this->db );
- $this->resetDB( $this->db, $this->tablesUsed );
- $this->addDBDataOnce();
- }
-
- $this->addDBData();
- $needsResetDB = true;
- }
-
- parent::run( $result );
-
- // We don't mind if we override already-overridden services during cleanup
- $this->overriddenServices = [];
-
- if ( $needsResetDB ) {
- $this->resetDB( $this->db, $this->tablesUsed );
- }
-
- self::restoreMwServices();
- $this->localServices = null;
- }
-
- /**
- * @return bool
- */
- private function oncePerClass() {
- // Remember current test class in the database connection,
- // so we know when we need to run addData.
-
- $class = static::class;
-
- $first = !isset( $this->db->_hasDataForTestClass )
- || $this->db->_hasDataForTestClass !== $class;
-
- $this->db->_hasDataForTestClass = $class;
- return $first;
- }
-
- /**
- * @since 1.21
- *
- * @return bool
- */
- public function usesTemporaryTables() {
- return self::$useTemporaryTables;
- }
-
- /**
- * Obtains a new temporary file name
- *
- * The obtained filename is enlisted to be removed upon tearDown
- *
- * @since 1.20
- *
- * @return string Absolute name of the temporary file
- */
- protected function getNewTempFile() {
- $fileName = tempnam(
- wfTempDir(),
- // Avoid backslashes here as they result in inconsistent results
- // between Windows and other OS, as well as between functions
- // that try to normalise these in one or both directions.
- // For example, tempnam rejects directory separators in the prefix which
- // means it rejects any namespaced class on Windows.
- // And then there is, wfMkdirParents which normalises paths always
- // whereas most other PHP and MW functions do not.
- 'MW_PHPUnit_' . strtr( static::class, [ '\\' => '_' ] ) . '_'
- );
- $this->tmpFiles[] = $fileName;
-
- return $fileName;
- }
-
- /**
- * obtains a new temporary directory
- *
- * The obtained directory is enlisted to be removed (recursively with all its contained
- * files) upon tearDown.
- *
- * @since 1.20
- *
- * @return string Absolute name of the temporary directory
- */
- protected function getNewTempDirectory() {
- // Starting of with a temporary *file*.
- $fileName = $this->getNewTempFile();
-
- // Converting the temporary file to a *directory*.
- // The following is not atomic, but at least we now have a single place,
- // where temporary directory creation is bundled and can be improved.
- unlink( $fileName );
- // If this fails for some reason, PHP will warn and fail the test.
- mkdir( $fileName, 0777, /* recursive = */ true );
-
- return $fileName;
- }
-
- protected function setUp() {
- parent::setUp();
- $this->called['setUp'] = true;
-
- $this->phpErrorLevel = intval( ini_get( 'error_reporting' ) );
-
- $this->overriddenServices = [];
-
- // Cleaning up temporary files
- foreach ( $this->tmpFiles as $fileName ) {
- if ( is_file( $fileName ) || ( is_link( $fileName ) ) ) {
- unlink( $fileName );
- } elseif ( is_dir( $fileName ) ) {
- wfRecursiveRemoveDir( $fileName );
- }
- }
-
- if ( $this->needsDB() && $this->db ) {
- // Clean up open transactions
- while ( $this->db->trxLevel() > 0 ) {
- $this->db->rollback( __METHOD__, 'flush' );
- }
- // Check for unsafe queries
- if ( $this->db->getType() === 'mysql' ) {
- $this->db->query( "SET sql_mode = 'STRICT_ALL_TABLES'", __METHOD__ );
- }
- }
-
- // Reset all caches between tests.
- self::resetNonServiceCaches();
-
- // XXX: reset maintenance triggers
- // Hook into period lag checks which often happen in long-running scripts
- $lbFactory = $this->localServices->getDBLoadBalancerFactory();
- Maintenance::setLBFactoryTriggers( $lbFactory, $this->localServices->getMainConfig() );
-
- ob_start( 'MediaWikiTestCase::wfResetOutputBuffersBarrier' );
- }
-
- protected function addTmpFiles( $files ) {
- $this->tmpFiles = array_merge( $this->tmpFiles, (array)$files );
- }
-
- // @todo Make const when we no longer support HHVM (T192166)
- private static $namespaceAffectingSettings = [
- 'wgAllowImageMoving',
- 'wgCanonicalNamespaceNames',
- 'wgCapitalLinkOverrides',
- 'wgCapitalLinks',
- 'wgContentNamespaces',
- 'wgExtensionMessagesFiles',
- 'wgExtensionNamespaces',
- 'wgExtraNamespaces',
- 'wgExtraSignatureNamespaces',
- 'wgNamespaceContentModels',
- 'wgNamespaceProtection',
- 'wgNamespacesWithSubpages',
- 'wgNonincludableNamespaces',
- 'wgRestrictionLevels',
- ];
-
- protected function tearDown() {
- global $wgRequest, $wgSQLMode;
-
- $status = ob_get_status();
- if ( isset( $status['name'] ) &&
- $status['name'] === 'MediaWikiTestCase::wfResetOutputBuffersBarrier'
- ) {
- ob_end_flush();
- }
-
- $this->called['tearDown'] = true;
- // Cleaning up temporary files
- foreach ( $this->tmpFiles as $fileName ) {
- if ( is_file( $fileName ) || ( is_link( $fileName ) ) ) {
- unlink( $fileName );
- } elseif ( is_dir( $fileName ) ) {
- wfRecursiveRemoveDir( $fileName );
- }
- }
-
- if ( $this->needsDB() && $this->db ) {
- // Clean up open transactions
- while ( $this->db->trxLevel() > 0 ) {
- $this->db->rollback( __METHOD__, 'flush' );
- }
- if ( $this->db->getType() === 'mysql' ) {
- $this->db->query( "SET sql_mode = " . $this->db->addQuotes( $wgSQLMode ),
- __METHOD__ );
- }
- }
-
- // Re-enable any disabled deprecation warnings
- MWDebug::clearLog();
- // Restore mw globals
- foreach ( $this->mwGlobals as $key => $value ) {
- $GLOBALS[$key] = $value;
- }
- foreach ( $this->mwGlobalsToUnset as $value ) {
- unset( $GLOBALS[$value] );
- }
- foreach ( $this->iniSettings as $name => $value ) {
- ini_set( $name, $value );
- }
- if (
- array_intersect( self::$namespaceAffectingSettings, array_keys( $this->mwGlobals ) ) ||
- array_intersect( self::$namespaceAffectingSettings, $this->mwGlobalsToUnset )
- ) {
- $this->resetNamespaces();
- }
- $this->mwGlobals = [];
- $this->mwGlobalsToUnset = [];
- $this->restoreLoggers();
-
- // TODO: move global state into MediaWikiServices
- RequestContext::resetMain();
- if ( session_id() !== '' ) {
- session_write_close();
- session_id( '' );
- }
- $wgRequest = new FauxRequest();
- MediaWiki\Session\SessionManager::resetCache();
- MediaWiki\Auth\AuthManager::resetCache();
-
- $phpErrorLevel = intval( ini_get( 'error_reporting' ) );
-
- if ( $phpErrorLevel !== $this->phpErrorLevel ) {
- ini_set( 'error_reporting', $this->phpErrorLevel );
-
- $oldHex = strtoupper( dechex( $this->phpErrorLevel ) );
- $newHex = strtoupper( dechex( $phpErrorLevel ) );
- $message = "PHP error_reporting setting was left dirty: "
- . "was 0x$oldHex before test, 0x$newHex after test!";
-
- $this->fail( $message );
- }
-
- parent::tearDown();
- }
-
- /**
- * Make sure MediaWikiTestCase extending classes have called their
- * parent setUp method
- *
- * With strict coverage activated in PHP_CodeCoverage, this test would be
- * marked as risky without the following annotation (T152923).
- * @coversNothing
- */
- final public function testMediaWikiTestCaseParentSetupCalled() {
- $this->assertArrayHasKey( 'setUp', $this->called,
- static::class . '::setUp() must call parent::setUp()'
- );
- }
-
- /**
- * Sets a service, maintaining a stashed version of the previous service to be
- * restored in tearDown
- *
- * @since 1.27
- *
- * @param string $name
- * @param object $object
- */
- protected function setService( $name, $object ) {
- if ( !$this->localServices ) {
- throw new Exception( __METHOD__ . ' must be called after MediaWikiTestCase::run()' );
- }
-
- if ( $this->localServices !== MediaWikiServices::getInstance() ) {
- throw new Exception( __METHOD__ . ' will not work because the global MediaWikiServices '
- . 'instance has been replaced by test code.' );
- }
-
- $this->overriddenServices[] = $name;
-
- $this->localServices->disableService( $name );
- $this->localServices->redefineService(
- $name,
- function () use ( $object ) {
- return $object;
- }
- );
-
- if ( $name === 'ContentLanguage' ) {
- $this->doSetMwGlobals( [ 'wgContLang' => $object ] );
- }
- }
-
- /**
- * Sets a global, maintaining a stashed version of the previous global to be
- * restored in tearDown
- *
- * The key is added to the array of globals that will be reset afterwards
- * in the tearDown().
- *
- * @par Example
- * @code
- * protected function setUp() {
- * $this->setMwGlobals( 'wgRestrictStuff', true );
- * }
- *
- * function testFoo() {}
- *
- * function testBar() {}
- * $this->assertTrue( self::getX()->doStuff() );
- *
- * $this->setMwGlobals( 'wgRestrictStuff', false );
- * $this->assertTrue( self::getX()->doStuff() );
- * }
- *
- * function testQuux() {}
- * @endcode
- *
- * @param array|string $pairs Key to the global variable, or an array
- * of key/value pairs.
- * @param mixed|null $value Value to set the global to (ignored
- * if an array is given as first argument).
- *
- * @note To allow changes to global variables to take effect on global service instances,
- * call overrideMwServices().
- *
- * @since 1.21
- */
- protected function setMwGlobals( $pairs, $value = null ) {
- if ( is_string( $pairs ) ) {
- $pairs = [ $pairs => $value ];
- }
-
- if ( isset( $pairs['wgContLang'] ) ) {
- throw new MWException(
- 'No setting $wgContLang, use setContentLang() or setService( \'ContentLanguage\' )'
- );
- }
-
- $this->doSetMwGlobals( $pairs, $value );
- }
-
- /**
- * An internal method that allows setService() to set globals that tests are not supposed to
- * touch.
- */
- private function doSetMwGlobals( $pairs, $value = null ) {
- $this->doStashMwGlobals( array_keys( $pairs ) );
-
- foreach ( $pairs as $key => $value ) {
- $GLOBALS[$key] = $value;
- }
-
- if ( array_intersect( self::$namespaceAffectingSettings, array_keys( $pairs ) ) ) {
- $this->resetNamespaces();
- }
- }
-
- /**
- * 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.
- */
- private function resetNamespaces() {
- if ( !$this->localServices ) {
- throw new Exception( __METHOD__ . ' must be called after MediaWikiTestCase::run()' );
- }
-
- if ( $this->localServices !== MediaWikiServices::getInstance() ) {
- throw new Exception( __METHOD__ . ' will not work because the global MediaWikiServices '
- . 'instance has been replaced by test code.' );
- }
-
- Language::clearCaches();
- }
-
- /**
- * Check if we can back up a value by performing a shallow copy.
- * Values which fail this test are copied recursively.
- *
- * @param mixed $value
- * @return bool True if a shallow copy will do; false if a deep copy
- * is required.
- */
- private static function canShallowCopy( $value ) {
- if ( is_scalar( $value ) || $value === null ) {
- return true;
- }
- if ( is_array( $value ) ) {
- foreach ( $value as $subValue ) {
- if ( !is_scalar( $subValue ) && $subValue !== null ) {
- return false;
- }
- }
- return true;
- }
- return false;
- }
-
- private function doStashMwGlobals( $globalKeys ) {
- if ( is_string( $globalKeys ) ) {
- $globalKeys = [ $globalKeys ];
- }
-
- foreach ( $globalKeys as $globalKey ) {
- // NOTE: make sure we only save the global once or a second call to
- // setMwGlobals() on the same global would override the original
- // value.
- if (
- !array_key_exists( $globalKey, $this->mwGlobals ) &&
- !array_key_exists( $globalKey, $this->mwGlobalsToUnset )
- ) {
- if ( !array_key_exists( $globalKey, $GLOBALS ) ) {
- $this->mwGlobalsToUnset[$globalKey] = $globalKey;
- continue;
- }
- // NOTE: we serialize then unserialize the value in case it is an object
- // this stops any objects being passed by reference. We could use clone
- // and if is_object but this does account for objects within objects!
- if ( self::canShallowCopy( $GLOBALS[$globalKey] ) ) {
- $this->mwGlobals[$globalKey] = $GLOBALS[$globalKey];
- } elseif (
- // Many MediaWiki types are safe to clone. These are the
- // ones that are most commonly stashed.
- $GLOBALS[$globalKey] instanceof Language ||
- $GLOBALS[$globalKey] instanceof User ||
- $GLOBALS[$globalKey] instanceof FauxRequest
- ) {
- $this->mwGlobals[$globalKey] = clone $GLOBALS[$globalKey];
- } elseif ( $this->containsClosure( $GLOBALS[$globalKey] ) ) {
- // Serializing Closure only gives a warning on HHVM while
- // it throws an Exception on Zend.
- // Workaround for https://github.com/facebook/hhvm/issues/6206
- $this->mwGlobals[$globalKey] = $GLOBALS[$globalKey];
- } else {
- try {
- $this->mwGlobals[$globalKey] = unserialize( serialize( $GLOBALS[$globalKey] ) );
- } catch ( Exception $e ) {
- $this->mwGlobals[$globalKey] = $GLOBALS[$globalKey];
- }
- }
- }
- }
- }
-
- /**
- * @param mixed $var
- * @param int $maxDepth
- *
- * @return bool
- */
- private function containsClosure( $var, $maxDepth = 15 ) {
- if ( $var instanceof Closure ) {
- return true;
- }
- if ( !is_array( $var ) || $maxDepth === 0 ) {
- return false;
- }
-
- foreach ( $var as $value ) {
- if ( $this->containsClosure( $value, $maxDepth - 1 ) ) {
- return true;
- }
- }
- return false;
- }
-
- /**
- * Merges the given values into a MW global array variable.
- * Useful for setting some entries in a configuration array, instead of
- * setting the entire array.
- *
- * @param string $name The name of the global, as in wgFooBar
- * @param array $values The array containing the entries to set in that global
- *
- * @throws MWException If the designated global is not an array.
- *
- * @note To allow changes to global variables to take effect on global service instances,
- * call overrideMwServices().
- *
- * @since 1.21
- */
- protected function mergeMwGlobalArrayValue( $name, $values ) {
- if ( !isset( $GLOBALS[$name] ) ) {
- $merged = $values;
- } else {
- if ( !is_array( $GLOBALS[$name] ) ) {
- throw new MWException( "MW global $name is not an array." );
- }
-
- // NOTE: do not use array_merge, it screws up for numeric keys.
- $merged = $GLOBALS[$name];
- foreach ( $values as $k => $v ) {
- $merged[$k] = $v;
- }
- }
-
- $this->setMwGlobals( $name, $merged );
- }
-
- /**
- * Stashes the global instance of MediaWikiServices, and installs a new one,
- * allowing test cases to override settings and services.
- * The previous instance of MediaWikiServices will be restored on tearDown.
- *
- * @since 1.27
- *
- * @param Config|null $configOverrides Configuration overrides for the new MediaWikiServices
- * instance.
- * @param callable[] $services An associative array of services to re-define. Keys are service
- * names, values are callables.
- *
- * @return MediaWikiServices
- * @throws MWException
- */
- protected function overrideMwServices(
- Config $configOverrides = null, array $services = []
- ) {
- if ( $this->overriddenServices ) {
- throw new MWException(
- 'The following services were set and are now being unset by overrideMwServices: ' .
- implode( ', ', $this->overriddenServices )
- );
- }
- $newInstance = self::installMockMwServices( $configOverrides );
-
- if ( $this->localServices ) {
- $this->localServices->destroy();
- }
-
- $this->localServices = $newInstance;
-
- foreach ( $services as $name => $callback ) {
- $newInstance->redefineService( $name, $callback );
- }
-
- self::resetGlobalParser();
-
- return $newInstance;
- }
-
- /**
- * Creates a new "mock" MediaWikiServices instance, and installs it.
- * This effectively resets all cached states in services, with the exception of
- * the ConfigFactory and the DBLoadBalancerFactory service, which are inherited from
- * the original MediaWikiServices.
- *
- * @note The new original MediaWikiServices instance can later be restored by calling
- * restoreMwServices(). That original is determined by the first call to this method, or
- * by setUpBeforeClass, whichever is called first. The caller is responsible for managing
- * and, when appropriate, destroying any other MediaWikiServices instances that may get
- * replaced when calling this method.
- *
- * @param Config|null $configOverrides Configuration overrides for the new MediaWikiServices
- * instance.
- *
- * @return MediaWikiServices the new mock service locator.
- */
- public static function installMockMwServices( Config $configOverrides = null ) {
- // Make sure we have the original service locator
- if ( !self::$originalServices ) {
- self::$originalServices = MediaWikiServices::getInstance();
- }
-
- if ( !$configOverrides ) {
- $configOverrides = new HashConfig();
- }
-
- $oldConfigFactory = self::$originalServices->getConfigFactory();
- $oldLoadBalancerFactory = self::$originalServices->getDBLoadBalancerFactory();
-
- $testConfig = self::makeTestConfig( null, $configOverrides );
- $newServices = new MediaWikiServices( $testConfig );
-
- // Load the default wiring from the specified files.
- // NOTE: this logic mirrors the logic in MediaWikiServices::newInstance.
- $wiringFiles = $testConfig->get( 'ServiceWiringFiles' );
- $newServices->loadWiringFiles( $wiringFiles );
-
- // Provide a traditional hook point to allow extensions to configure services.
- Hooks::run( 'MediaWikiServices', [ $newServices ] );
-
- // Use bootstrap config for all configuration.
- // This allows config overrides via global variables to take effect.
- $bootstrapConfig = $newServices->getBootstrapConfig();
- $newServices->resetServiceForTesting( 'ConfigFactory' );
- $newServices->redefineService(
- 'ConfigFactory',
- self::makeTestConfigFactoryInstantiator(
- $oldConfigFactory,
- [ 'main' => $bootstrapConfig ]
- )
- );
- $newServices->resetServiceForTesting( 'DBLoadBalancerFactory' );
- $newServices->redefineService(
- 'DBLoadBalancerFactory',
- function ( MediaWikiServices $services ) use ( $oldLoadBalancerFactory ) {
- return $oldLoadBalancerFactory;
- }
- );
-
- MediaWikiServices::forceGlobalInstance( $newServices );
-
- self::resetGlobalParser();
-
- return $newServices;
- }
-
- /**
- * Restores the original, non-mock MediaWikiServices instance.
- * The previously active MediaWikiServices instance is destroyed,
- * if it is different from the original that is to be restored.
- *
- * @note this if for internal use by test framework code. It should never be
- * called from inside a test case, a data provider, or a setUp or tearDown method.
- *
- * @return bool true if the original service locator was restored,
- * false if there was nothing too do.
- */
- public static function restoreMwServices() {
- if ( !self::$originalServices ) {
- return false;
- }
-
- $currentServices = MediaWikiServices::getInstance();
-
- if ( self::$originalServices === $currentServices ) {
- return false;
- }
-
- MediaWikiServices::forceGlobalInstance( self::$originalServices );
- $currentServices->destroy();
-
- self::resetGlobalParser();
-
- return true;
- }
-
- /**
- * If $wgParser has been unstubbed, replace it with a fresh one so it picks up any config
- * changes. $wgParser is deprecated, but we still support it for now.
- */
- private static function resetGlobalParser() {
- // phpcs:ignore MediaWiki.Usage.DeprecatedGlobalVariables.Deprecated$wgParser
- global $wgParser;
- if ( $wgParser instanceof StubObject ) {
- return;
- }
- $wgParser = new StubObject( 'wgParser', function () {
- return MediaWikiServices::getInstance()->getParser();
- } );
- }
-
- /**
- * @since 1.27
- * @param string|Language $lang
- */
- public function setUserLang( $lang ) {
- RequestContext::getMain()->setLanguage( $lang );
- $this->setMwGlobals( 'wgLang', RequestContext::getMain()->getLanguage() );
- }
-
- /**
- * @since 1.27
- * @param string|Language $lang
- */
- public function setContentLang( $lang ) {
- if ( $lang instanceof Language ) {
- $this->setMwGlobals( 'wgLanguageCode', $lang->getCode() );
- // Set to the exact object requested
- $this->setService( 'ContentLanguage', $lang );
- } else {
- $this->setMwGlobals( 'wgLanguageCode', $lang );
- // Let the service handler make up the object. Avoid calling setService(), because if
- // we do, overrideMwServices() will complain if it's called later on.
- $services = MediaWikiServices::getInstance();
- $services->resetServiceForTesting( 'ContentLanguage' );
- $this->doSetMwGlobals( [ 'wgContLang' => $services->getContentLanguage() ] );
- }
- }
-
- /**
- * Alters $wgGroupPermissions for the duration of the test. Can be called
- * with an array, like
- * [ '*' => [ 'read' => false ], 'user' => [ 'read' => false ] ]
- * or three values to set a single permission, like
- * $this->setGroupPermissions( '*', 'read', false );
- *
- * @since 1.31
- * @param array|string $newPerms Either an array of permissions to change,
- * in which case the next two parameters are ignored; or a single string
- * identifying a group, to use with the next two parameters.
- * @param string|null $newKey
- * @param mixed|null $newValue
- */
- public function setGroupPermissions( $newPerms, $newKey = null, $newValue = null ) {
- global $wgGroupPermissions;
-
- if ( is_string( $newPerms ) ) {
- $newPerms = [ $newPerms => [ $newKey => $newValue ] ];
- }
-
- $newPermissions = $wgGroupPermissions;
- foreach ( $newPerms as $group => $permissions ) {
- foreach ( $permissions as $key => $value ) {
- $newPermissions[$group][$key] = $value;
- }
- }
-
- $this->setMwGlobals( 'wgGroupPermissions', $newPermissions );
- }
-
- /**
- * Sets the logger for a specified channel, for the duration of the test.
- * @since 1.27
- * @param string $channel
- * @param LoggerInterface $logger
- */
- protected function setLogger( $channel, LoggerInterface $logger ) {
- // TODO: Once loggers are managed by MediaWikiServices, use
- // overrideMwServices() to set loggers.
-
- $provider = LoggerFactory::getProvider();
- $wrappedProvider = TestingAccessWrapper::newFromObject( $provider );
- $singletons = $wrappedProvider->singletons;
- if ( $provider instanceof MonologSpi ) {
- if ( !isset( $this->loggers[$channel] ) ) {
- $this->loggers[$channel] = $singletons['loggers'][$channel] ?? null;
- }
- $singletons['loggers'][$channel] = $logger;
- } elseif ( $provider instanceof LegacySpi || $provider instanceof LogCapturingSpi ) {
- if ( !isset( $this->loggers[$channel] ) ) {
- $this->loggers[$channel] = $singletons[$channel] ?? null;
- }
- $singletons[$channel] = $logger;
- } else {
- throw new LogicException( __METHOD__ . ': setting a logger for ' . get_class( $provider )
- . ' is not implemented' );
- }
- $wrappedProvider->singletons = $singletons;
- }
-
- /**
- * Restores loggers replaced by setLogger().
- * @since 1.27
- */
- private function restoreLoggers() {
- $provider = LoggerFactory::getProvider();
- $wrappedProvider = TestingAccessWrapper::newFromObject( $provider );
- $singletons = $wrappedProvider->singletons;
- foreach ( $this->loggers as $channel => $logger ) {
- if ( $provider instanceof MonologSpi ) {
- if ( $logger === null ) {
- unset( $singletons['loggers'][$channel] );
- } else {
- $singletons['loggers'][$channel] = $logger;
- }
- } elseif ( $provider instanceof LegacySpi || $provider instanceof LogCapturingSpi ) {
- if ( $logger === null ) {
- unset( $singletons[$channel] );
- } else {
- $singletons[$channel] = $logger;
- }
- }
- }
- $wrappedProvider->singletons = $singletons;
- $this->loggers = [];
- }
-
- /**
- * @return string
- * @since 1.18
- */
- public function dbPrefix() {
- return self::getTestPrefixFor( $this->db );
- }
-
- /**
- * @param IDatabase $db
- * @return string
- * @since 1.32
- */
- public static function getTestPrefixFor( IDatabase $db ) {
- return $db->getType() == 'oracle' ? self::ORA_DB_PREFIX : self::DB_PREFIX;
- }
-
- /**
- * @return bool
- * @since 1.18
- */
- public function needsDB() {
- // If the test says it uses database tables, it needs the database
- return $this->tablesUsed || $this->isTestInDatabaseGroup();
- }
-
- /**
- * @return bool
- * @since 1.32
- */
- protected function isTestInDatabaseGroup() {
- // If the test class says it belongs to the Database group, it needs the database.
- // NOTE: This ONLY checks for the group in the class level doc comment.
- $rc = new ReflectionClass( $this );
- return (bool)preg_match( '/@group +Database/im', $rc->getDocComment() );
- }
-
- /**
- * Insert a new page.
- *
- * Should be called from addDBData().
- *
- * @since 1.25 ($namespace in 1.28)
- * @param string|Title $pageName Page name or title
- * @param string $text Page's content
- * @param int|null $namespace Namespace id (name cannot already contain namespace)
- * @param User|null $user If null, static::getTestSysop()->getUser() is used.
- * @return array Title object and page id
- * @throws MWException If this test cases's needsDB() method doesn't return true.
- * Test cases can use "@group Database" to enable database test support,
- * or list the tables under testing in $this->tablesUsed, or override the
- * needsDB() method.
- */
- protected function insertPage(
- $pageName,
- $text = 'Sample page for unit test.',
- $namespace = null,
- User $user = null
- ) {
- if ( !$this->needsDB() ) {
- throw new MWException( 'When testing which pages, the test cases\'s needsDB()' .
- ' method should return true. Use @group Database or $this->tablesUsed.' );
- }
-
- if ( is_string( $pageName ) ) {
- $title = Title::newFromText( $pageName, $namespace );
- } else {
- $title = $pageName;
- }
-
- if ( !$user ) {
- $user = static::getTestSysop()->getUser();
- }
- $comment = __METHOD__ . ': Sample page for unit test.';
-
- $page = WikiPage::factory( $title );
- $page->doEditContent( ContentHandler::makeContent( $text, $title ), $comment, 0, false, $user );
-
- return [
- 'title' => $title,
- 'id' => $page->getId(),
- ];
- }
-
- /**
- * Stub. If a test suite needs to add additional data to the database, it should
- * implement this method and do so. This method is called once per test suite
- * (i.e. once per class).
- *
- * Note data added by this method may be removed by resetDB() depending on
- * the contents of $tablesUsed.
- *
- * To add additional data between test function runs, override prepareDB().
- *
- * @see addDBData()
- * @see resetDB()
- *
- * @since 1.27
- */
- public function addDBDataOnce() {
- }
-
- /**
- * Stub. Subclasses may override this to prepare the database.
- * Called before every test run (test function or data set).
- *
- * @see addDBDataOnce()
- * @see resetDB()
- *
- * @since 1.18
- */
- public function addDBData() {
- }
-
- /**
- * @since 1.32
- */
- protected function addCoreDBData() {
- if ( $this->db->getType() == 'oracle' ) {
- # Insert 0 user to prevent FK violations
- # Anonymous user
- if ( !$this->db->selectField( 'user', '1', [ 'user_id' => 0 ] ) ) {
- $this->db->insert( 'user', [
- 'user_id' => 0,
- 'user_name' => 'Anonymous' ], __METHOD__, [ 'IGNORE' ] );
- }
-
- # Insert 0 page to prevent FK violations
- # Blank page
- if ( !$this->db->selectField( 'page', '1', [ 'page_id' => 0 ] ) ) {
- $this->db->insert( 'page', [
- 'page_id' => 0,
- 'page_namespace' => 0,
- 'page_title' => ' ',
- 'page_restrictions' => null,
- 'page_is_redirect' => 0,
- 'page_is_new' => 0,
- 'page_random' => 0,
- 'page_touched' => $this->db->timestamp(),
- 'page_latest' => 0,
- 'page_len' => 0 ], __METHOD__, [ 'IGNORE' ] );
- }
- }
-
- SiteStatsInit::doPlaceholderInit();
-
- User::resetIdByNameCache();
-
- // Make sysop user
- $user = static::getTestSysop()->getUser();
-
- // Make 1 page with 1 revision
- $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
- if ( $page->getId() == 0 ) {
- $page->doEditContent(
- new WikitextContent( 'UTContent' ),
- 'UTPageSummary',
- EDIT_NEW | EDIT_SUPPRESS_RC,
- false,
- $user
- );
- // an edit always attempt to purge backlink links such as history
- // pages. That is unnecessary.
- JobQueueGroup::singleton()->get( 'htmlCacheUpdate' )->delete();
- // WikiPages::doEditUpdates randomly adds RC purges
- JobQueueGroup::singleton()->get( 'recentChangesUpdate' )->delete();
-
- // doEditContent() probably started the session via
- // User::loadFromSession(). Close it now.
- if ( session_id() !== '' ) {
- session_write_close();
- session_id( '' );
- }
- }
- }
-
- /**
- * Restores MediaWiki to using the table set (table prefix) it was using before
- * setupTestDB() was called. Useful if we need to perform database operations
- * after the test run has finished (such as saving logs or profiling info).
- *
- * This is called by phpunit/bootstrap.php after the last test.
- *
- * @since 1.21
- */
- public static function teardownTestDB() {
- global $wgJobClasses;
-
- if ( !self::$dbSetup ) {
- return;
- }
-
- Hooks::run( 'UnitTestsBeforeDatabaseTeardown' );
-
- foreach ( $wgJobClasses as $type => $class ) {
- // Delete any jobs under the clone DB (or old prefix in other stores)
- JobQueueGroup::singleton()->get( $type )->delete();
- }
-
- // T219673: close any connections from code that failed to call reuseConnection()
- // or is still holding onto a DBConnRef instance (e.g. in a singleton).
- MediaWikiServices::getInstance()->getDBLoadBalancerFactory()->closeAll();
- CloneDatabase::changePrefix( self::$oldTablePrefix );
-
- self::$oldTablePrefix = false;
- self::$dbSetup = false;
- }
-
- /**
- * Setups a database with cloned tables using the given prefix.
- *
- * If reuseDB is true and certain conditions apply, it will just change the prefix.
- * Otherwise, it will clone the tables and change the prefix.
- *
- * @param IMaintainableDatabase $db Database to use
- * @param string|null $prefix Prefix to use for test tables. If not given, the prefix is determined
- * automatically for $db.
- * @return bool True if tables were cloned, false if only the prefix was changed
- */
- protected static function setupDatabaseWithTestPrefix(
- IMaintainableDatabase $db,
- $prefix = null
- ) {
- if ( $prefix === null ) {
- $prefix = self::getTestPrefixFor( $db );
- }
-
- if ( ( $db->getType() == 'oracle' || !self::$useTemporaryTables ) && self::$reuseDB ) {
- $db->tablePrefix( $prefix );
- return false;
- }
-
- if ( !isset( $db->_originalTablePrefix ) ) {
- $oldPrefix = $db->tablePrefix();
-
- if ( $oldPrefix === $prefix ) {
- // table already has the correct prefix, but presumably no cloned tables
- $oldPrefix = self::$oldTablePrefix;
- }
-
- $db->tablePrefix( $oldPrefix );
- $tablesCloned = self::listTables( $db );
- $dbClone = new CloneDatabase( $db, $tablesCloned, $prefix, $oldPrefix );
- $dbClone->useTemporaryTables( self::$useTemporaryTables );
-
- $dbClone->cloneTableStructure();
-
- $db->tablePrefix( $prefix );
- $db->_originalTablePrefix = $oldPrefix;
- }
-
- return true;
- }
-
- /**
- * Set up all test DBs
- */
- public function setupAllTestDBs() {
- global $wgDBprefix;
-
- self::$oldTablePrefix = $wgDBprefix;
-
- $testPrefix = $this->dbPrefix();
-
- // switch to a temporary clone of the database
- self::setupTestDB( $this->db, $testPrefix );
-
- if ( self::isUsingExternalStoreDB() ) {
- self::setupExternalStoreTestDBs( $testPrefix );
- }
-
- // NOTE: Change the prefix in the LBFactory and $wgDBprefix, to prevent
- // *any* database connections to operate on live data.
- CloneDatabase::changePrefix( $testPrefix );
- }
-
- /**
- * Creates an empty skeleton of the wiki database by cloning its structure
- * to equivalent tables using the given $prefix. Then sets MediaWiki to
- * use the new set of tables (aka schema) instead of the original set.
- *
- * This is used to generate a dummy table set, typically consisting of temporary
- * tables, that will be used by tests instead of the original wiki database tables.
- *
- * @since 1.21
- *
- * @note the original table prefix is stored in self::$oldTablePrefix. This is used
- * by teardownTestDB() to return the wiki to using the original table set.
- *
- * @note this method only works when first called. Subsequent calls have no effect,
- * even if using different parameters.
- *
- * @param IMaintainableDatabase $db The database connection
- * @param string $prefix The prefix to use for the new table set (aka schema).
- *
- * @throws MWException If the database table prefix is already $prefix
- */
- public static function setupTestDB( IMaintainableDatabase $db, $prefix ) {
- if ( self::$dbSetup ) {
- return;
- }
-
- if ( $db->tablePrefix() === $prefix ) {
- throw new MWException(
- 'Cannot run unit tests, the database prefix is already "' . $prefix . '"' );
- }
-
- // TODO: the below should be re-written as soon as LBFactory, LoadBalancer,
- // and Database no longer use global state.
-
- self::$dbSetup = true;
-
- if ( !self::setupDatabaseWithTestPrefix( $db, $prefix ) ) {
- return;
- }
-
- // Assuming this isn't needed for External Store database, and not sure if the procedure
- // would be available there.
- if ( $db->getType() == 'oracle' ) {
- $db->query( 'BEGIN FILL_WIKI_INFO; END;', __METHOD__ );
- }
-
- Hooks::run( 'UnitTestsAfterDatabaseSetup', [ $db, $prefix ] );
- }
-
- /**
- * Clones the External Store database(s) for testing
- *
- * @param string|null $testPrefix Prefix for test tables. Will be determined automatically
- * if not given.
- */
- protected static function setupExternalStoreTestDBs( $testPrefix = null ) {
- $connections = self::getExternalStoreDatabaseConnections();
- foreach ( $connections as $dbw ) {
- self::setupDatabaseWithTestPrefix( $dbw, $testPrefix );
- }
- }
-
- /**
- * Gets master database connections for all of the ExternalStoreDB
- * stores configured in $wgDefaultExternalStore.
- *
- * @return Database[] Array of Database master connections
- */
- protected static function getExternalStoreDatabaseConnections() {
- global $wgDefaultExternalStore;
-
- /** @var ExternalStoreDB $externalStoreDB */
- $externalStoreDB = ExternalStore::getStoreObject( 'DB' );
- $defaultArray = (array)$wgDefaultExternalStore;
- $dbws = [];
- foreach ( $defaultArray as $url ) {
- if ( strpos( $url, 'DB://' ) === 0 ) {
- list( $proto, $cluster ) = explode( '://', $url, 2 );
- // Avoid getMaster() because setupDatabaseWithTestPrefix()
- // requires Database instead of plain DBConnRef/IDatabase
- $dbws[] = $externalStoreDB->getMaster( $cluster );
- }
- }
-
- return $dbws;
- }
-
- /**
- * Check whether ExternalStoreDB is being used
- *
- * @return bool True if it's being used
- */
- protected static function isUsingExternalStoreDB() {
- global $wgDefaultExternalStore;
- if ( !$wgDefaultExternalStore ) {
- return false;
- }
-
- $defaultArray = (array)$wgDefaultExternalStore;
- foreach ( $defaultArray as $url ) {
- if ( strpos( $url, 'DB://' ) === 0 ) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * @throws LogicException if the given database connection is not a set up to use
- * mock tables.
- *
- * @since 1.31 this is no longer private.
- */
- protected function ensureMockDatabaseConnection( IDatabase $db ) {
- if ( $db->tablePrefix() !== $this->dbPrefix() ) {
- throw new LogicException(
- 'Trying to delete mock tables, but table prefix does not indicate a mock database.'
- );
- }
- }
-
- private static $schemaOverrideDefaults = [
- 'scripts' => [],
- 'create' => [],
- 'drop' => [],
- 'alter' => [],
- ];
-
- /**
- * Stub. If a test suite needs to test against a specific database schema, it should
- * override this method and return the appropriate information from it.
- *
- * 'create', 'drop' and 'alter' in the returned array should list all the tables affected
- * by the 'scripts', even if the test is only interested in a subset of them, otherwise
- * the overrides may not be fully cleaned up, leading to errors later.
- *
- * @param IMaintainableDatabase $db The DB connection to use for the mock schema.
- * May be used to check the current state of the schema, to determine what
- * overrides are needed.
- *
- * @return array An associative array with the following fields:
- * - 'scripts': any SQL scripts to run. If empty or not present, schema overrides are skipped.
- * - 'create': A list of tables created (may or may not exist in the original schema).
- * - 'drop': A list of tables dropped (expected to be present in the original schema).
- * - 'alter': A list of tables altered (expected to be present in the original schema).
- */
- protected function getSchemaOverrides( IMaintainableDatabase $db ) {
- return [];
- }
-
- /**
- * Undoes the specified schema overrides..
- * Called once per test class, just before addDataOnce().
- *
- * @param IMaintainableDatabase $db
- * @param array $oldOverrides
- */
- private function undoSchemaOverrides( IMaintainableDatabase $db, $oldOverrides ) {
- $this->ensureMockDatabaseConnection( $db );
-
- $oldOverrides = $oldOverrides + self::$schemaOverrideDefaults;
- $originalTables = $this->listOriginalTables( $db );
-
- // Drop tables that need to be restored or removed.
- $tablesToDrop = array_merge( $oldOverrides['create'], $oldOverrides['alter'] );
-
- // Restore tables that have been dropped or created or altered,
- // if they exist in the original schema.
- $tablesToRestore = array_merge( $tablesToDrop, $oldOverrides['drop'] );
- $tablesToRestore = array_intersect( $originalTables, $tablesToRestore );
-
- if ( $tablesToDrop ) {
- $this->dropMockTables( $db, $tablesToDrop );
- }
-
- if ( $tablesToRestore ) {
- $this->recloneMockTables( $db, $tablesToRestore );
-
- // Reset the restored tables, mainly for the side effect of
- // re-calling $this->addCoreDBData() if necessary.
- $this->resetDB( $db, $tablesToRestore );
- }
- }
-
- /**
- * Applies the schema overrides returned by getSchemaOverrides(),
- * after undoing any previously applied schema overrides.
- * Called once per test class, just before addDataOnce().
- */
- private function setUpSchema( IMaintainableDatabase $db ) {
- // Undo any active overrides.
- $oldOverrides = $db->_schemaOverrides ?? self::$schemaOverrideDefaults;
-
- if ( $oldOverrides['alter'] || $oldOverrides['create'] || $oldOverrides['drop'] ) {
- $this->undoSchemaOverrides( $db, $oldOverrides );
- unset( $db->_schemaOverrides );
- }
-
- // Determine new overrides.
- $overrides = $this->getSchemaOverrides( $db ) + self::$schemaOverrideDefaults;
-
- $extraKeys = array_diff(
- array_keys( $overrides ),
- array_keys( self::$schemaOverrideDefaults )
- );
-
- if ( $extraKeys ) {
- throw new InvalidArgumentException(
- 'Schema override contains extra keys: ' . var_export( $extraKeys, true )
- );
- }
-
- if ( !$overrides['scripts'] ) {
- // no scripts to run
- return;
- }
-
- if ( !$overrides['create'] && !$overrides['drop'] && !$overrides['alter'] ) {
- throw new InvalidArgumentException(
- 'Schema override scripts given, but no tables are declared to be '
- . 'created, dropped or altered.'
- );
- }
-
- $this->ensureMockDatabaseConnection( $db );
-
- // Drop the tables that will be created by the schema scripts.
- $originalTables = $this->listOriginalTables( $db );
- $tablesToDrop = array_intersect( $originalTables, $overrides['create'] );
-
- if ( $tablesToDrop ) {
- $this->dropMockTables( $db, $tablesToDrop );
- }
-
- // Run schema override scripts.
- foreach ( $overrides['scripts'] as $script ) {
- $db->sourceFile(
- $script,
- null,
- null,
- __METHOD__,
- function ( $cmd ) {
- return $this->mungeSchemaUpdateQuery( $cmd );
- }
- );
- }
-
- $db->_schemaOverrides = $overrides;
- }
-
- private function mungeSchemaUpdateQuery( $cmd ) {
- return self::$useTemporaryTables
- ? preg_replace( '/\bCREATE\s+TABLE\b/i', 'CREATE TEMPORARY TABLE', $cmd )
- : $cmd;
- }
-
- /**
- * Drops the given mock tables.
- *
- * @param IMaintainableDatabase $db
- * @param array $tables
- */
- private function dropMockTables( IMaintainableDatabase $db, array $tables ) {
- $this->ensureMockDatabaseConnection( $db );
-
- foreach ( $tables as $tbl ) {
- $tbl = $db->tableName( $tbl );
- $db->query( "DROP TABLE IF EXISTS $tbl", __METHOD__ );
- }
- }
-
- /**
- * Lists all tables in the live database schema, without a prefix.
- *
- * @param IMaintainableDatabase $db
- * @return array
- */
- private function listOriginalTables( IMaintainableDatabase $db ) {
- if ( !isset( $db->_originalTablePrefix ) ) {
- throw new LogicException( 'No original table prefix know, cannot list tables!' );
- }
-
- $originalTables = $db->listTables( $db->_originalTablePrefix, __METHOD__ );
-
- $unittestPrefixRegex = '/^' . preg_quote( $this->dbPrefix(), '/' ) . '/';
- $originalPrefixRegex = '/^' . preg_quote( $db->_originalTablePrefix, '/' ) . '/';
-
- $originalTables = array_filter(
- $originalTables,
- function ( $pt ) use ( $unittestPrefixRegex ) {
- return !preg_match( $unittestPrefixRegex, $pt );
- }
- );
-
- $originalTables = array_map(
- function ( $pt ) use ( $originalPrefixRegex ) {
- return preg_replace( $originalPrefixRegex, '', $pt );
- },
- $originalTables
- );
-
- return array_unique( $originalTables );
- }
-
- /**
- * Re-clones the given mock tables to restore them based on the live database schema.
- * The tables listed in $tables are expected to currently not exist, so dropMockTables()
- * should be called first.
- *
- * @param IMaintainableDatabase $db
- * @param array $tables
- */
- private function recloneMockTables( IMaintainableDatabase $db, array $tables ) {
- $this->ensureMockDatabaseConnection( $db );
-
- if ( !isset( $db->_originalTablePrefix ) ) {
- throw new LogicException( 'No original table prefix know, cannot restore tables!' );
- }
-
- $originalTables = $this->listOriginalTables( $db );
- $tables = array_intersect( $tables, $originalTables );
-
- $dbClone = new CloneDatabase( $db, $tables, $db->tablePrefix(), $db->_originalTablePrefix );
- $dbClone->useTemporaryTables( self::$useTemporaryTables );
-
- $dbClone->cloneTableStructure();
- }
-
- /**
- * Empty all tables so they can be repopulated for tests
- *
- * @param Database $db|null Database to reset
- * @param array $tablesUsed Tables to reset
- */
- private function resetDB( $db, $tablesUsed ) {
- if ( $db ) {
- $userTables = [ 'user', 'user_groups', 'user_properties', 'actor' ];
- $pageTables = [
- 'page', 'revision', 'ip_changes', 'revision_comment_temp', 'comment', 'archive',
- 'revision_actor_temp', 'slots', 'content', 'content_models', 'slot_roles',
- ];
- $coreDBDataTables = array_merge( $userTables, $pageTables );
-
- // If any of the user or page tables were marked as used, we should clear all of them.
- if ( array_intersect( $tablesUsed, $userTables ) ) {
- $tablesUsed = array_unique( array_merge( $tablesUsed, $userTables ) );
- TestUserRegistry::clear();
-
- // Reset $wgUser, which is probably 127.0.0.1, as its loaded data is probably not valid
- // @todo Should we start setting $wgUser to something nondeterministic
- // to encourage tests to be updated to not depend on it?
- global $wgUser;
- $wgUser->clearInstanceCache( $wgUser->mFrom );
- }
- if ( array_intersect( $tablesUsed, $pageTables ) ) {
- $tablesUsed = array_unique( array_merge( $tablesUsed, $pageTables ) );
- }
-
- // Postgres, Oracle, and MSSQL all use mwuser/pagecontent
- // instead of user/text. But Postgres does not remap the
- // table name in tableExists(), so we mark the real table
- // names as being used.
- if ( $db->getType() === 'postgres' ) {
- if ( in_array( 'user', $tablesUsed ) ) {
- $tablesUsed[] = 'mwuser';
- }
- if ( in_array( 'text', $tablesUsed ) ) {
- $tablesUsed[] = 'pagecontent';
- }
- }
-
- foreach ( $tablesUsed as $tbl ) {
- $this->truncateTable( $tbl, $db );
- }
-
- if ( array_intersect( $tablesUsed, $coreDBDataTables ) ) {
- // Reset services that may contain information relating to the truncated tables
- $this->overrideMwServices();
- // Re-add core DB data that was deleted
- $this->addCoreDBData();
- }
- }
- }
-
- /**
- * Empties the given table and resets any auto-increment counters.
- * Will also purge caches associated with some well known tables.
- * If the table is not know, this method just returns.
- *
- * @param string $tableName
- * @param IDatabase|null $db
- */
- protected function truncateTable( $tableName, IDatabase $db = null ) {
- if ( !$db ) {
- $db = $this->db;
- }
-
- if ( !$db->tableExists( $tableName ) ) {
- return;
- }
-
- $truncate = in_array( $db->getType(), [ 'oracle', 'mysql' ] );
-
- if ( $truncate ) {
- $db->query( 'TRUNCATE TABLE ' . $db->tableName( $tableName ), __METHOD__ );
- } else {
- $db->delete( $tableName, '*', __METHOD__ );
- }
-
- if ( $db instanceof DatabasePostgres || $db instanceof DatabaseSqlite ) {
- // Reset the table's sequence too.
- $db->resetSequenceForTable( $tableName, __METHOD__ );
- }
-
- // re-initialize site_stats table
- if ( $tableName === 'site_stats' ) {
- SiteStatsInit::doPlaceholderInit();
- }
- }
-
- private static function unprefixTable( &$tableName, $ind, $prefix ) {
- $tableName = substr( $tableName, strlen( $prefix ) );
- }
-
- private static function isNotUnittest( $table ) {
- return strpos( $table, self::DB_PREFIX ) !== 0;
- }
-
- /**
- * @since 1.18
- *
- * @param IMaintainableDatabase $db
- *
- * @return array
- */
- public static function listTables( IMaintainableDatabase $db ) {
- $prefix = $db->tablePrefix();
- $tables = $db->listTables( $prefix, __METHOD__ );
-
- if ( $db->getType() === 'mysql' ) {
- static $viewListCache = null;
- if ( $viewListCache === null ) {
- $viewListCache = $db->listViews( null, __METHOD__ );
- }
- // T45571: cannot clone VIEWs under MySQL
- $tables = array_diff( $tables, $viewListCache );
- }
- array_walk( $tables, [ __CLASS__, 'unprefixTable' ], $prefix );
-
- // Don't duplicate test tables from the previous fataled run
- $tables = array_filter( $tables, [ __CLASS__, 'isNotUnittest' ] );
-
- if ( $db->getType() == 'sqlite' ) {
- $tables = array_flip( $tables );
- // these are subtables of searchindex and don't need to be duped/dropped separately
- unset( $tables['searchindex_content'] );
- unset( $tables['searchindex_segdir'] );
- unset( $tables['searchindex_segments'] );
- $tables = array_flip( $tables );
- }
-
- return $tables;
- }
-
- /**
- * Copy test data from one database connection to another.
- *
- * This should only be used for small data sets.
- *
- * @param IDatabase $source
- * @param IDatabase $target
- */
- public function copyTestData( IDatabase $source, IDatabase $target ) {
- if ( $this->db->getType() === 'sqlite' ) {
- // SQLite uses a non-temporary copy of the searchindex table for testing,
- // which gets deleted and re-created when setting up the secondary connection,
- // causing "Error 17" when trying to copy the data. See T191863#4130112.
- throw new RuntimeException(
- 'Setting up a secondary database connection with test data is currently not'
- . 'with SQLite. You may want to use markTestSkippedIfDbType() to bypass this issue.'
- );
- }
-
- $tables = self::listOriginalTables( $source );
-
- foreach ( $tables as $table ) {
- $res = $source->select( $table, '*', [], __METHOD__ );
- $allRows = [];
-
- foreach ( $res as $row ) {
- $allRows[] = (array)$row;
- }
-
- $target->insert( $table, $allRows, __METHOD__, [ 'IGNORE' ] );
- }
- }
-
- /**
- * @throws MWException
- * @since 1.18
- */
- protected function checkDbIsSupported() {
- if ( !in_array( $this->db->getType(), $this->supportedDBs ) ) {
- throw new MWException( $this->db->getType() . " is not currently supported for unit testing." );
- }
- }
-
- /**
- * @since 1.18
- * @param string $offset
- * @return mixed
- */
- public function getCliArg( $offset ) {
- return $this->cliArgs[$offset] ?? null;
- }
-
- /**
- * @since 1.18
- * @param string $offset
- * @param mixed $value
- */
- public function setCliArg( $offset, $value ) {
- $this->cliArgs[$offset] = $value;
- }
-
- /**
- * Don't throw a warning if $function is deprecated and called later
- *
- * @since 1.19
- *
- * @param string $function
- */
- public function hideDeprecated( $function ) {
- Wikimedia\suppressWarnings();
- wfDeprecated( $function );
- Wikimedia\restoreWarnings();
- }
-
- /**
- * Asserts that the given database query yields the rows given by $expectedRows.
- * The expected rows should be given as indexed (not associative) arrays, with
- * the values given in the order of the columns in the $fields parameter.
- * Note that the rows are sorted by the columns given in $fields.
- *
- * @since 1.20
- *
- * @param string|array $table The table(s) to query
- * @param string|array $fields The columns to include in the result (and to sort by)
- * @param string|array $condition "where" condition(s)
- * @param array $expectedRows An array of arrays giving the expected rows.
- * @param array $options Options for the query
- * @param array $join_conds Join conditions for the query
- *
- * @throws MWException If this test cases's needsDB() method doesn't return true.
- * Test cases can use "@group Database" to enable database test support,
- * or list the tables under testing in $this->tablesUsed, or override the
- * needsDB() method.
- */
- protected function assertSelect(
- $table, $fields, $condition, array $expectedRows, array $options = [], array $join_conds = []
- ) {
- if ( !$this->needsDB() ) {
- throw new MWException( 'When testing database state, the test cases\'s needDB()' .
- ' method should return true. Use @group Database or $this->tablesUsed.' );
- }
-
- $db = wfGetDB( DB_REPLICA );
-
- $res = $db->select(
- $table,
- $fields,
- $condition,
- wfGetCaller(),
- $options + [ 'ORDER BY' => $fields ],
- $join_conds
- );
- $this->assertNotEmpty( $res, "query failed: " . $db->lastError() );
-
- $i = 0;
-
- foreach ( $expectedRows as $expected ) {
- $r = $res->fetchRow();
- self::stripStringKeys( $r );
-
- $i += 1;
- $this->assertNotEmpty( $r, "row #$i missing" );
-
- $this->assertEquals( $expected, $r, "row #$i mismatches" );
- }
-
- $r = $res->fetchRow();
- self::stripStringKeys( $r );
-
- $this->assertFalse( $r, "found extra row (after #$i)" );
- }
-
- /**
- * Utility method taking an array of elements and wrapping
- * each element in its own array. Useful for data providers
- * that only return a single argument.
- *
- * @since 1.20
- *
- * @param array $elements
- *
- * @return array
- */
- protected function arrayWrap( array $elements ) {
- return array_map(
- function ( $element ) {
- return [ $element ];
- },
- $elements
- );
- }
-
- /**
- * Assert that two arrays are equal. By default this means that both arrays need to hold
- * the same set of values. Using additional arguments, order and associated key can also
- * be set as relevant.
- *
- * @since 1.20
- *
- * @param array $expected
- * @param array $actual
- * @param bool $ordered If the order of the values should match
- * @param bool $named If the keys should match
- */
- protected function assertArrayEquals( array $expected, array $actual,
- $ordered = false, $named = false
- ) {
- if ( !$ordered ) {
- $this->objectAssociativeSort( $expected );
- $this->objectAssociativeSort( $actual );
- }
-
- if ( !$named ) {
- $expected = array_values( $expected );
- $actual = array_values( $actual );
- }
-
- call_user_func_array(
- [ $this, 'assertEquals' ],
- array_merge( [ $expected, $actual ], array_slice( func_get_args(), 4 ) )
- );
- }
-
- /**
- * Put each HTML element on its own line and then equals() the results
- *
- * Use for nicely formatting of PHPUnit diff output when comparing very
- * simple HTML
- *
- * @since 1.20
- *
- * @param string $expected HTML on oneline
- * @param string $actual HTML on oneline
- * @param string $msg Optional message
- */
- protected function assertHTMLEquals( $expected, $actual, $msg = '' ) {
- $expected = str_replace( '>', ">\n", $expected );
- $actual = str_replace( '>', ">\n", $actual );
-
- $this->assertEquals( $expected, $actual, $msg );
- }
-
- /**
- * Does an associative sort that works for objects.
- *
- * @since 1.20
- *
- * @param array &$array
- */
- protected function objectAssociativeSort( array &$array ) {
- uasort(
- $array,
- function ( $a, $b ) {
- return serialize( $a ) <=> serialize( $b );
- }
- );
- }
-
- /**
- * Utility function for eliminating all string keys from an array.
- * Useful to turn a database result row as returned by fetchRow() into
- * a pure indexed array.
- *
- * @since 1.20
- *
- * @param mixed &$r The array to remove string keys from.
- */
- protected static function stripStringKeys( &$r ) {
- if ( !is_array( $r ) ) {
- return;
- }
-
- foreach ( $r as $k => $v ) {
- if ( is_string( $k ) ) {
- unset( $r[$k] );
- }
- }
- }
-
- /**
- * Asserts that the provided variable is of the specified
- * internal type or equals the $value argument. This is useful
- * for testing return types of functions that return a certain
- * type or *value* when not set or on error.
- *
- * @since 1.20
- *
- * @param string $type
- * @param mixed $actual
- * @param mixed $value
- * @param string $message
- */
- protected function assertTypeOrValue( $type, $actual, $value = false, $message = '' ) {
- if ( $actual === $value ) {
- $this->assertTrue( true, $message );
- } else {
- $this->assertType( $type, $actual, $message );
- }
- }
-
- /**
- * Asserts the type of the provided value. This can be either
- * in internal type such as boolean or integer, or a class or
- * interface the value extends or implements.
- *
- * @since 1.20
- *
- * @param string $type
- * @param mixed $actual
- * @param string $message
- */
- protected function assertType( $type, $actual, $message = '' ) {
- if ( class_exists( $type ) || interface_exists( $type ) ) {
- $this->assertInstanceOf( $type, $actual, $message );
- } else {
- $this->assertInternalType( $type, $actual, $message );
- }
- }
-
- /**
- * Returns true if the given namespace defaults to Wikitext
- * according to $wgNamespaceContentModels
- *
- * @param int $ns The namespace ID to check
- *
- * @return bool
- * @since 1.21
- */
- protected function isWikitextNS( $ns ) {
- global $wgNamespaceContentModels;
-
- if ( isset( $wgNamespaceContentModels[$ns] ) ) {
- return $wgNamespaceContentModels[$ns] === CONTENT_MODEL_WIKITEXT;
- }
-
- return true;
- }
-
- /**
- * Returns the ID of a namespace that defaults to Wikitext.
- *
- * @throws MWException If there is none.
- * @return int The ID of the wikitext Namespace
- * @since 1.21
- */
- protected function getDefaultWikitextNS() {
- global $wgNamespaceContentModels;
-
- static $wikitextNS = null; // this is not going to change
- if ( $wikitextNS !== null ) {
- return $wikitextNS;
- }
-
- // quickly short out on most common case:
- if ( !isset( $wgNamespaceContentModels[NS_MAIN] ) ) {
- return NS_MAIN;
- }
-
- // NOTE: prefer content namespaces
- $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
- $namespaces = array_unique( array_merge(
- $nsInfo->getContentNamespaces(),
- [ NS_MAIN, NS_HELP, NS_PROJECT ], // prefer these
- $nsInfo->getValidNamespaces()
- ) );
-
- $namespaces = array_diff( $namespaces, [
- NS_FILE, NS_CATEGORY, NS_MEDIAWIKI, NS_USER // don't mess with magic namespaces
- ] );
-
- $talk = array_filter( $namespaces, function ( $ns ) use ( $nsInfo ) {
- return $nsInfo->isTalk( $ns );
- } );
-
- // prefer non-talk pages
- $namespaces = array_diff( $namespaces, $talk );
- $namespaces = array_merge( $namespaces, $talk );
-
- // check default content model of each namespace
- foreach ( $namespaces as $ns ) {
- if ( !isset( $wgNamespaceContentModels[$ns] ) ||
- $wgNamespaceContentModels[$ns] === CONTENT_MODEL_WIKITEXT
- ) {
- $wikitextNS = $ns;
-
- return $wikitextNS;
- }
- }
-
- // give up
- // @todo Inside a test, we could skip the test as incomplete.
- // But frequently, this is used in fixture setup.
- throw new MWException( "No namespace defaults to wikitext!" );
- }
-
- /**
- * Check, if $wgDiff3 is set and ready to merge
- * Will mark the calling test as skipped, if not ready
- *
- * @since 1.21
- */
- protected function markTestSkippedIfNoDiff3() {
- global $wgDiff3;
-
- # This check may also protect against code injection in
- # case of broken installations.
- Wikimedia\suppressWarnings();
- $haveDiff3 = $wgDiff3 && file_exists( $wgDiff3 );
- Wikimedia\restoreWarnings();
-
- if ( !$haveDiff3 ) {
- $this->markTestSkipped( "Skip test, since diff3 is not configured" );
- }
- }
-
- /**
- * Check if $extName is a loaded PHP extension, will skip the
- * test whenever it is not loaded.
- *
- * @since 1.21
- * @param string $extName
- * @return bool
- */
- protected function checkPHPExtension( $extName ) {
- $loaded = extension_loaded( $extName );
- if ( !$loaded ) {
- $this->markTestSkipped( "PHP extension '$extName' is not loaded, skipping." );
- }
-
- return $loaded;
- }
-
- /**
- * Skip the test if using the specified database type
- *
- * @param string $type Database type
- * @since 1.32
- */
- protected function markTestSkippedIfDbType( $type ) {
- if ( $this->db->getType() === $type ) {
- $this->markTestSkipped( "The $type database type isn't supported for this test" );
- }
- }
-
- /**
- * Used as a marker to prevent wfResetOutputBuffers from breaking PHPUnit.
- * @param string $buffer
- * @return string
- */
- public static function wfResetOutputBuffersBarrier( $buffer ) {
- return $buffer;
- }
-
- /**
- * Create a temporary hook handler which will be reset by tearDown.
- * This replaces other handlers for the same hook.
- * @param string $hookName Hook name
- * @param mixed $handler Value suitable for a hook handler
- * @since 1.28
- */
- protected function setTemporaryHook( $hookName, $handler ) {
- $this->mergeMwGlobalArrayValue( 'wgHooks', [ $hookName => [ $handler ] ] );
- }
-
- /**
- * Check whether file contains given data.
- * @param string $fileName
- * @param string $actualData
- * @param bool $createIfMissing If true, and file does not exist, create it with given data
- * and skip the test.
- * @param string $msg
- * @since 1.30
- */
- protected function assertFileContains(
- $fileName,
- $actualData,
- $createIfMissing = false,
- $msg = ''
- ) {
- if ( $createIfMissing ) {
- if ( !file_exists( $fileName ) ) {
- file_put_contents( $fileName, $actualData );
- $this->markTestSkipped( "Data file $fileName does not exist" );
- }
- } else {
- self::assertFileExists( $fileName );
- }
- self::assertEquals( file_get_contents( $fileName ), $actualData, $msg );
- }
-
- /**
- * Edits or creates a page/revision
- * @param string $pageName Page title
- * @param string $text Content of the page
- * @param string $summary Optional summary string for the revision
- * @param int $defaultNs Optional namespace id
- * @param User|null $user If null, static::getTestSysop()->getUser() is used.
- * @return Status Object as returned by WikiPage::doEditContent()
- * @throws MWException If this test cases's needsDB() method doesn't return true.
- * Test cases can use "@group Database" to enable database test support,
- * or list the tables under testing in $this->tablesUsed, or override the
- * needsDB() method.
- */
- protected function editPage(
- $pageName,
- $text,
- $summary = '',
- $defaultNs = NS_MAIN,
- User $user = null
- ) {
- if ( !$this->needsDB() ) {
- throw new MWException( 'When testing which pages, the test cases\'s needsDB()' .
- ' method should return true. Use @group Database or $this->tablesUsed.' );
- }
-
- $title = Title::newFromText( $pageName, $defaultNs );
- $page = WikiPage::factory( $title );
-
- return $page->doEditContent(
- ContentHandler::makeContent( $text, $title ),
- $summary,
- 0,
- false,
- $user
- );
- }
-
- /**
- * Revision-deletes a revision.
- *
- * @param Revision|int $rev Revision to delete
- * @param array $value Keys are Revision::DELETED_* flags. Values are 1 to set the bit, 0 to
- * clear, -1 to leave alone. (All other values also clear the bit.)
- * @param string $comment Deletion comment
- */
- protected function revisionDelete(
- $rev, array $value = [ Revision::DELETED_TEXT => 1 ], $comment = ''
- ) {
- if ( is_int( $rev ) ) {
- $rev = Revision::newFromId( $rev );
- }
- RevisionDeleter::createList(
- 'revision', RequestContext::getMain(), $rev->getTitle(), [ $rev->getId() ]
- )->setVisibility( [
- 'value' => $value,
- 'comment' => $comment,
- ] );
- }
-
- /**
- * Returns a PHPUnit constraint that matches anything other than a fixed set of values. This can
- * be used to whitelist values, e.g.
- * $mock->expects( $this->never() )->method( $this->anythingBut( 'foo', 'bar' ) );
- * which will throw if any unexpected method is called.
- *
- * @param mixed ...$values Values that are not matched
- */
- protected function anythingBut( ...$values ) {
- return $this->logicalNot( $this->logicalOr(
- ...array_map( [ $this, 'matches' ], $values )
- ) );
- }
-}
<?php
/**
- * Base class for MediaWiki unit tests.
- *
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
use PHPUnit\Framework\TestCase;
+/**
+ * Base class for unit tests.
+ *
+ * Extend this class if you are testing classes which use dependency injection and do not access
+ * global functions, variables, services or a storage backend.
+ */
abstract class MediaWikiUnitTestCase extends TestCase {
use PHPUnit4And6Compat;
use MediaWikiCoversValidator;
--- /dev/null
+<?php
+/**
+ * Bootstrapping for MediaWiki PHPUnit tests when called via the maintenance class tests runner.
+ * This file is included by phpunit and is NOT in the global scope.
+ *
+ * @file
+ */
+
+if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+ echo <<<EOF
+You are running these tests directly from phpunit. You may not have all globals correctly set.
+Running phpunit.php instead is recommended.
+EOF;
+ require_once __DIR__ . "/phpunit.php";
+}
+
+// The PHPUnit_TextUI_TestRunner class will run each test suite and may call
+// exit() with an exit status code. As such, we cannot run code "after the last test"
+// by adding statements to PHPUnitMaintClass::execute or MediaWikiPHPUnitCommand::run.
+// Instead, we work around it by registering a shutdown callback from the bootstrap
+// file, which runs before PHPUnit starts.
+// @todo Once we use PHPUnit 8 or higher, use the 'AfterLastTestHook' feature.
+// https://phpunit.readthedocs.io/en/8.0/extending-phpunit.html#available-hook-interfaces
+register_shutdown_function( function () {
+ // This will:
+ // - clear the temporary job queue.
+ // - allow extensions to delete any temporary tables they created.
+ // - restore ability to connect to the real database,
+ // (for logging profiling data).
+ MediaWikiTestCase::teardownTestDB();
+
+ // Log profiling data, e.g. in the database or UDP
+ wfLogProfilingData();
+} );
<?php
+
/**
- * Bootstrapping for MediaWiki PHPUnit tests
- * This file is included by phpunit and is NOT in the global scope.
+ * PHPUnit bootstrap file.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
*
* @file
+ * @ingroup Testing
+ */
+
+if ( PHP_SAPI !== 'cli' ) {
+ die( 'This file is only meant to be executed indirectly by PHPUnit\'s bootstrap process!' );
+}
+
+/**
+ * PHPUnit includes the bootstrap file inside a method body, while most MediaWiki startup files
+ * assume to be included in the global scope.
+ * This utility provides a way to include these files: it makes all globals available in the
+ * inclusion scope before including the file, then exports all new or changed globals.
+ *
+ * @param string $fileName the file to include
*/
+function wfRequireOnceInGlobalScope( $fileName ) {
+ // phpcs:disable MediaWiki.Usage.ForbiddenFunctions.extract
+ extract( $GLOBALS, EXTR_REFS | EXTR_SKIP );
+ // phpcs:enable
-if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
- echo <<<EOF
-You are running these tests directly from phpunit. You may not have all globals correctly set.
-Running phpunit.php instead is recommended.
-EOF;
- require_once __DIR__ . "/phpunit.php";
+ require_once $fileName;
+
+ foreach ( get_defined_vars() as $varName => $value ) {
+ $GLOBALS[$varName] = $value;
+ }
}
-// The PHPUnit_TextUI_TestRunner class will run each test suite and may call
-// exit() with an exit status code. As such, we cannot run code "after the last test"
-// by adding statements to PHPUnitMaintClass::execute or MediaWikiPHPUnitCommand::run.
-// Instead, we work around it by registering a shutdown callback from the bootstrap
-// file, which runs before PHPUnit starts.
-// @todo Once we use PHPUnit 8 or higher, use the 'AfterLastTestHook' feature.
-// https://phpunit.readthedocs.io/en/8.0/extending-phpunit.html#available-hook-interfaces
-register_shutdown_function( function () {
- // This will:
- // - clear the temporary job queue.
- // - allow extensions to delete any temporary tables they created.
- // - restore ability to connect to the real database,
- // (for logging profiling data).
- MediaWikiTestCase::teardownTestDB();
-
- // Log profiling data, e.g. in the database or UDP
- wfLogProfilingData();
-} );
+define( 'MEDIAWIKI', true );
+define( 'MW_PHPUNIT_TEST', true );
+
+// We don't use a settings file here but some code still assumes that one exists
+define( 'MW_CONFIG_FILE', 'LocalSettings.php' );
+
+$IP = realpath( __DIR__ . '/../../' );
+
+// these variables must be defined before setup runs
+$GLOBALS['IP'] = $IP;
+// Faking for Setup.php
+$GLOBALS['wgScopeTest'] = 'MediaWiki Setup.php scope test';
+$GLOBALS['wgCommandLineMode'] = true;
+$GLOBALS['wgAutoloadClasses'] = [];
+
+require_once "$IP/tests/common/TestSetup.php";
+
+wfRequireOnceInGlobalScope( "$IP/includes/AutoLoader.php" );
+wfRequireOnceInGlobalScope( "$IP/tests/common/TestsAutoLoader.php" );
+++ /dev/null
-<?php
-
-use Psr\Log\NullLogger;
-use Wikimedia\Rdbms\Blob;
-use Wikimedia\Rdbms\Database;
-use Wikimedia\Rdbms\DatabaseSqlite;
-use Wikimedia\Rdbms\ResultWrapper;
-use Wikimedia\Rdbms\TransactionProfiler;
-use Wikimedia\TestingAccessWrapper;
-
-/**
- * @group sqlite
- * @group Database
- * @group medium
- */
-class DatabaseSqliteTest extends MediaWikiTestCase {
- /** @var DatabaseSqlite */
- protected $db;
-
- protected function setUp() {
- parent::setUp();
-
- if ( !Sqlite::isPresent() ) {
- $this->markTestSkipped( 'No SQLite support detected' );
- }
- $this->db = $this->getMockBuilder( DatabaseSqlite::class )
- ->setConstructorArgs( [ [
- 'dbFilePath' => ':memory:',
- 'schema' => false,
- 'host' => false,
- 'user' => false,
- 'password' => false,
- 'tablePrefix' => '',
- 'cliMode' => true,
- 'agent' => 'unit-tests',
- 'flags' => DBO_DEFAULT,
- 'variables' => [],
- 'profiler' => null,
- 'trxProfiler' => new TransactionProfiler(),
- 'connLogger' => new NullLogger(),
- 'queryLogger' => new NullLogger(),
- 'errorLogger' => null,
- 'deprecationLogger' => null,
- ] ] )->setMethods( [ 'query' ] )
- ->getMock();
- $this->db->initConnection();
- $this->db->method( 'query' )->willReturn( true );
- if ( version_compare( $this->db->getServerVersion(), '3.6.0', '<' ) ) {
- $this->markTestSkipped( "SQLite at least 3.6 required, {$this->db->getServerVersion()} found" );
- }
- }
-
- /**
- * @param $sql
- * @return string|string[]|null
- */
- private function replaceVars( $sql ) {
- $wrapper = TestingAccessWrapper::newFromObject( $this->db );
- // normalize spacing to hide implementation details
- return preg_replace( '/\s+/', ' ', $wrapper->replaceVars( $sql ) );
- }
-
- private function assertResultIs( $expected, $res ) {
- $this->assertNotNull( $res );
- $i = 0;
- foreach ( $res as $row ) {
- foreach ( $expected[$i] as $key => $value ) {
- $this->assertTrue( isset( $row->$key ) );
- $this->assertEquals( $value, $row->$key );
- }
- $i++;
- }
- $this->assertEquals( count( $expected ), $i, 'Unexpected number of rows' );
- }
-
- public static function provideAddQuotes() {
- return [
- [ // #0: empty
- '', "''"
- ],
- [ // #1: simple
- 'foo bar', "'foo bar'"
- ],
- [ // #2: including quote
- 'foo\'bar', "'foo''bar'"
- ],
- // #3: including \0 (must be represented as hex, per https://bugs.php.net/bug.php?id=63419)
- [
- "x\0y",
- "x'780079'",
- ],
- [ // #4: blob object (must be represented as hex)
- new Blob( "hello" ),
- "x'68656c6c6f'",
- ],
- [ // #5: null
- null,
- "''",
- ],
- ];
- }
-
- /**
- * @dataProvider provideAddQuotes()
- * @covers DatabaseSqlite::addQuotes
- */
- public function testAddQuotes( $value, $expected ) {
- // check quoting
- $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
- $this->assertEquals( $expected, $db->addQuotes( $value ), 'string not quoted as expected' );
-
- // ok, quoting works as expected, now try a round trip.
- $re = $db->query( 'select ' . $db->addQuotes( $value ) );
-
- $this->assertTrue( $re !== false, 'query failed' );
-
- $row = $re->fetchRow();
- if ( $row ) {
- if ( $value instanceof Blob ) {
- $value = $value->fetch();
- }
-
- $this->assertEquals( $value, $row[0], 'string mangled by the database' );
- } else {
- $this->fail( 'query returned no result' );
- }
- }
-
- /**
- * @covers DatabaseSqlite::replaceVars
- */
- public function testReplaceVars() {
- $this->assertEquals( 'foo', $this->replaceVars( 'foo' ), "Don't break anything accidentally" );
-
- $this->assertEquals(
- "CREATE TABLE /**/foo (foo_key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "
- . "foo_bar TEXT, foo_name TEXT NOT NULL DEFAULT '', foo_int INTEGER, foo_int2 INTEGER );",
- $this->replaceVars(
- "CREATE TABLE /**/foo (foo_key int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT, "
- . "foo_bar char(13), foo_name varchar(255) binary NOT NULL DEFAULT '', "
- . "foo_int tinyint ( 8 ), foo_int2 int(16) ) ENGINE=MyISAM;"
- )
- );
-
- $this->assertEquals(
- "CREATE TABLE foo ( foo1 REAL, foo2 REAL, foo3 REAL );",
- $this->replaceVars(
- "CREATE TABLE foo ( foo1 FLOAT, foo2 DOUBLE( 1,10), foo3 DOUBLE PRECISION );"
- )
- );
-
- $this->assertEquals( "CREATE TABLE foo ( foo_binary1 BLOB, foo_binary2 BLOB );",
- $this->replaceVars( "CREATE TABLE foo ( foo_binary1 binary(16), foo_binary2 varbinary(32) );" )
- );
-
- $this->assertEquals( "CREATE TABLE text ( text_foo TEXT );",
- $this->replaceVars( "CREATE TABLE text ( text_foo tinytext );" ),
- 'Table name changed'
- );
-
- $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );",
- $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY NOT NULL AUTO_INCREMENT );" )
- );
- $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );",
- $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY AUTO_INCREMENT NOT NULL );" )
- );
-
- $this->assertEquals( "CREATE TABLE enums( enum1 TEXT, myenum TEXT)",
- $this->replaceVars( "CREATE TABLE enums( enum1 ENUM('A', 'B'), myenum ENUM ('X', 'Y'))" )
- );
-
- $this->assertEquals( "ALTER TABLE foo ADD COLUMN foo_bar INTEGER DEFAULT 42",
- $this->replaceVars( "ALTER TABLE foo\nADD COLUMN foo_bar int(10) unsigned DEFAULT 42" )
- );
-
- $this->assertEquals( "DROP INDEX foo",
- $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar" )
- );
-
- $this->assertEquals( "DROP INDEX foo -- dropping index",
- $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar -- dropping index" )
- );
- $this->assertEquals( "INSERT OR IGNORE INTO foo VALUES ('bar')",
- $this->replaceVars( "INSERT OR IGNORE INTO foo VALUES ('bar')" )
- );
- }
-
- /**
- * @covers DatabaseSqlite::tableName
- */
- public function testTableName() {
- // @todo Moar!
- $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
- $this->assertEquals( 'foo', $db->tableName( 'foo' ) );
- $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) );
- $db->tablePrefix( 'foo_' );
- $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) );
- $this->assertEquals( 'foo_bar', $db->tableName( 'bar' ) );
- }
-
- /**
- * @covers DatabaseSqlite::duplicateTableStructure
- */
- public function testDuplicateTableStructure() {
- $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
- $db->query( 'CREATE TABLE foo(foo, barfoo)' );
- $db->query( 'CREATE INDEX index1 ON foo(foo)' );
- $db->query( 'CREATE UNIQUE INDEX index2 ON foo(barfoo)' );
-
- $db->duplicateTableStructure( 'foo', 'bar' );
- $this->assertEquals( 'CREATE TABLE "bar"(foo, barfoo)',
- $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'bar' ] ),
- 'Normal table duplication'
- );
- $indexList = $db->query( 'PRAGMA INDEX_LIST("bar")' );
- $index = $indexList->next();
- $this->assertEquals( 'bar_index1', $index->name );
- $this->assertEquals( '0', $index->unique );
- $index = $indexList->next();
- $this->assertEquals( 'bar_index2', $index->name );
- $this->assertEquals( '1', $index->unique );
-
- $db->duplicateTableStructure( 'foo', 'baz', true );
- $this->assertEquals( 'CREATE TABLE "baz"(foo, barfoo)',
- $db->selectField( 'sqlite_temp_master', 'sql', [ 'name' => 'baz' ] ),
- 'Creation of temporary duplicate'
- );
- $indexList = $db->query( 'PRAGMA INDEX_LIST("baz")' );
- $index = $indexList->next();
- $this->assertEquals( 'baz_index1', $index->name );
- $this->assertEquals( '0', $index->unique );
- $index = $indexList->next();
- $this->assertEquals( 'baz_index2', $index->name );
- $this->assertEquals( '1', $index->unique );
- $this->assertEquals( 0,
- $db->selectField( 'sqlite_master', 'COUNT(*)', [ 'name' => 'baz' ] ),
- 'Create a temporary duplicate only'
- );
- }
-
- /**
- * @covers DatabaseSqlite::duplicateTableStructure
- */
- public function testDuplicateTableStructureVirtual() {
- $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
- if ( $db->getFulltextSearchModule() != 'FTS3' ) {
- $this->markTestSkipped( 'FTS3 not supported, cannot create virtual tables' );
- }
- $db->query( 'CREATE VIRTUAL TABLE "foo" USING FTS3(foobar)' );
-
- $db->duplicateTableStructure( 'foo', 'bar' );
- $this->assertEquals( 'CREATE VIRTUAL TABLE "bar" USING FTS3(foobar)',
- $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'bar' ] ),
- 'Duplication of virtual tables'
- );
-
- $db->duplicateTableStructure( 'foo', 'baz', true );
- $this->assertEquals( 'CREATE VIRTUAL TABLE "baz" USING FTS3(foobar)',
- $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'baz' ] ),
- "Can't create temporary virtual tables, should fall back to non-temporary duplication"
- );
- }
-
- /**
- * @covers DatabaseSqlite::deleteJoin
- */
- public function testDeleteJoin() {
- $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
- $db->query( 'CREATE TABLE a (a_1)', __METHOD__ );
- $db->query( 'CREATE TABLE b (b_1, b_2)', __METHOD__ );
- $db->insert( 'a', [
- [ 'a_1' => 1 ],
- [ 'a_1' => 2 ],
- [ 'a_1' => 3 ],
- ],
- __METHOD__
- );
- $db->insert( 'b', [
- [ 'b_1' => 2, 'b_2' => 'a' ],
- [ 'b_1' => 3, 'b_2' => 'b' ],
- ],
- __METHOD__
- );
- $db->deleteJoin( 'a', 'b', 'a_1', 'b_1', [ 'b_2' => 'a' ], __METHOD__ );
- $res = $db->query( "SELECT * FROM a", __METHOD__ );
- $this->assertResultIs( [
- [ 'a_1' => 1 ],
- [ 'a_1' => 3 ],
- ],
- $res
- );
- }
-
- /**
- * @coversNothing
- */
- public function testEntireSchema() {
- global $IP;
-
- $result = Sqlite::checkSqlSyntax( "$IP/maintenance/tables.sql" );
- if ( $result !== true ) {
- $this->fail( $result );
- }
- $this->assertTrue( true ); // avoid test being marked as incomplete due to lack of assertions
- }
-
- /**
- * Runs upgrades of older databases and compares results with current schema
- * @todo Currently only checks list of tables
- * @coversNothing
- */
- public function testUpgrades() {
- global $IP, $wgVersion, $wgProfiler;
-
- // Versions tested
- $versions = [
- // '1.13', disabled for now, was totally screwed up
- // SQLite wasn't included in 1.14
- '1.15',
- '1.16',
- '1.17',
- '1.18',
- '1.19',
- '1.20',
- '1.21',
- '1.22',
- '1.23',
- ];
-
- // Mismatches for these columns we can safely ignore
- $ignoredColumns = [
- 'user_newtalk.user_last_timestamp', // r84185
- ];
-
- $currentDB = DatabaseSqlite::newStandaloneInstance( ':memory:' );
- $currentDB->sourceFile( "$IP/maintenance/tables.sql" );
-
- $profileToDb = false;
- if ( isset( $wgProfiler['output'] ) ) {
- $out = $wgProfiler['output'];
- if ( $out === 'db' ) {
- $profileToDb = true;
- } elseif ( is_array( $out ) && in_array( 'db', $out ) ) {
- $profileToDb = true;
- }
- }
-
- if ( $profileToDb ) {
- $currentDB->sourceFile( "$IP/maintenance/sqlite/archives/patch-profiling.sql" );
- }
- $currentTables = $this->getTables( $currentDB );
- sort( $currentTables );
-
- foreach ( $versions as $version ) {
- $versions = "upgrading from $version to $wgVersion";
- $db = $this->prepareTestDB( $version );
- $tables = $this->getTables( $db );
- $this->assertEquals( $currentTables, $tables, "Different tables $versions" );
- foreach ( $tables as $table ) {
- $currentCols = $this->getColumns( $currentDB, $table );
- $cols = $this->getColumns( $db, $table );
- $this->assertEquals(
- array_keys( $currentCols ),
- array_keys( $cols ),
- "Mismatching columns for table \"$table\" $versions"
- );
- foreach ( $currentCols as $name => $column ) {
- $fullName = "$table.$name";
- $this->assertEquals(
- (bool)$column->pk,
- (bool)$cols[$name]->pk,
- "PRIMARY KEY status does not match for column $fullName $versions"
- );
- if ( !in_array( $fullName, $ignoredColumns ) ) {
- $this->assertEquals(
- (bool)$column->notnull,
- (bool)$cols[$name]->notnull,
- "NOT NULL status does not match for column $fullName $versions"
- );
- $this->assertEquals(
- $column->dflt_value,
- $cols[$name]->dflt_value,
- "Default values does not match for column $fullName $versions"
- );
- }
- }
- $currentIndexes = $this->getIndexes( $currentDB, $table );
- $indexes = $this->getIndexes( $db, $table );
- $this->assertEquals(
- array_keys( $currentIndexes ),
- array_keys( $indexes ),
- "mismatching indexes for table \"$table\" $versions"
- );
- }
- $db->close();
- }
- }
-
- /**
- * @covers DatabaseSqlite::insertId
- */
- public function testInsertIdType() {
- $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
-
- $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ );
- $this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Database creation" );
-
- $insertion = $db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ );
- $this->assertTrue( $insertion, "Insertion worked" );
-
- $this->assertInternalType( 'integer', $db->insertId(), "Actual typecheck" );
- $this->assertTrue( $db->close(), "closing database" );
- }
-
- /**
- * @covers DatabaseSqlite::insert
- */
- public function testInsertAffectedRows() {
- $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
- $db->query( 'CREATE TABLE testInsertAffectedRows ( foo )', __METHOD__ );
-
- $insertion = $db->insert(
- 'testInsertAffectedRows',
- [
- [ 'foo' => 10 ],
- [ 'foo' => 12 ],
- [ 'foo' => 1555 ],
- ],
- __METHOD__
- );
- $this->assertTrue( $insertion, "Insertion worked" );
-
- $this->assertSame( 3, $db->affectedRows() );
- $this->assertTrue( $db->close(), "closing database" );
- }
-
- private function prepareTestDB( $version ) {
- static $maint = null;
- if ( $maint === null ) {
- $maint = new FakeMaintenance();
- $maint->loadParamsAndArgs( null, [ 'quiet' => 1 ] );
- }
-
- global $IP;
- $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
- $db->sourceFile( "$IP/tests/phpunit/data/db/sqlite/tables-$version.sql" );
- $updater = DatabaseUpdater::newForDB( $db, false, $maint );
- $updater->doUpdates( [ 'core' ] );
-
- return $db;
- }
-
- private function getTables( $db ) {
- $list = array_flip( $db->listTables() );
- $excluded = [
- 'external_user', // removed from core in 1.22
- 'math', // moved out of core in 1.18
- 'trackbacks', // removed from core in 1.19
- 'searchindex',
- 'searchindex_content',
- 'searchindex_segments',
- 'searchindex_segdir',
- // FTS4 ready!!1
- 'searchindex_docsize',
- 'searchindex_stat',
- ];
- foreach ( $excluded as $t ) {
- unset( $list[$t] );
- }
- $list = array_flip( $list );
- sort( $list );
-
- return $list;
- }
-
- private function getColumns( $db, $table ) {
- $cols = [];
- $res = $db->query( "PRAGMA table_info($table)" );
- $this->assertNotNull( $res );
- foreach ( $res as $col ) {
- $cols[$col->name] = $col;
- }
- ksort( $cols );
-
- return $cols;
- }
-
- private function getIndexes( $db, $table ) {
- $indexes = [];
- $res = $db->query( "PRAGMA index_list($table)" );
- $this->assertNotNull( $res );
- foreach ( $res as $index ) {
- $res2 = $db->query( "PRAGMA index_info({$index->name})" );
- $this->assertNotNull( $res2 );
- $index->columns = [];
- foreach ( $res2 as $col ) {
- $index->columns[] = $col;
- }
- $indexes[$index->name] = $index;
- }
- ksort( $indexes );
-
- return $indexes;
- }
-
- /**
- * @coversNothing
- */
- public function testCaseInsensitiveLike() {
- // TODO: Test this for all databases
- $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
- $res = $db->query( 'SELECT "a" LIKE "A" AS a' );
- $row = $res->fetchRow();
- $this->assertFalse( (bool)$row['a'] );
- }
-
- /**
- * @covers DatabaseSqlite::numFields
- */
- public function testNumFields() {
- $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
-
- $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ );
- $this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Failed to create table a" );
- $res = $db->select( 'a', '*' );
- $this->assertEquals( 0, $db->numFields( $res ), "expects to get 0 fields for an empty table" );
- $insertion = $db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ );
- $this->assertTrue( $insertion, "Insertion failed" );
- $res = $db->select( 'a', '*' );
- $this->assertEquals( 1, $db->numFields( $res ), "wrong number of fields" );
-
- $this->assertTrue( $db->close(), "closing database" );
- }
-
- /**
- * @covers \Wikimedia\Rdbms\DatabaseSqlite::__toString
- */
- public function testToString() {
- $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
-
- $toString = (string)$db;
-
- $this->assertContains( 'sqlite object', $toString );
- }
-
- /**
- * @covers \Wikimedia\Rdbms\DatabaseSqlite::getAttributes()
- */
- public function testsAttributes() {
- $attributes = Database::attributesFromType( 'sqlite' );
- $this->assertTrue( $attributes[Database::ATTR_DB_LEVEL_LOCKING] );
- }
-}
--- /dev/null
+<?php
+
+use Psr\Log\NullLogger;
+use Wikimedia\Rdbms\Blob;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\DatabaseSqlite;
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\TransactionProfiler;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group sqlite
+ * @group Database
+ * @group medium
+ */
+class DatabaseSqliteTest extends \MediaWikiIntegrationTestCase {
+ /** @var DatabaseSqlite */
+ protected $db;
+
+ protected function setUp() {
+ parent::setUp();
+
+ if ( !Sqlite::isPresent() ) {
+ $this->markTestSkipped( 'No SQLite support detected' );
+ }
+ $this->db = $this->getMockBuilder( DatabaseSqlite::class )
+ ->setConstructorArgs( [ [
+ 'dbFilePath' => ':memory:',
+ 'schema' => false,
+ 'host' => false,
+ 'user' => false,
+ 'password' => false,
+ 'tablePrefix' => '',
+ 'cliMode' => true,
+ 'agent' => 'unit-tests',
+ 'flags' => DBO_DEFAULT,
+ 'variables' => [],
+ 'profiler' => null,
+ 'trxProfiler' => new TransactionProfiler(),
+ 'connLogger' => new NullLogger(),
+ 'queryLogger' => new NullLogger(),
+ 'errorLogger' => null,
+ 'deprecationLogger' => null,
+ ] ] )->setMethods( [ 'query' ] )
+ ->getMock();
+ $this->db->initConnection();
+ $this->db->method( 'query' )->willReturn( true );
+ if ( version_compare( $this->db->getServerVersion(), '3.6.0', '<' ) ) {
+ $this->markTestSkipped( "SQLite at least 3.6 required, {$this->db->getServerVersion()} found" );
+ }
+ }
+
+ /**
+ * @param $sql
+ * @return string|string[]|null
+ */
+ private function replaceVars( $sql ) {
+ $wrapper = TestingAccessWrapper::newFromObject( $this->db );
+ // normalize spacing to hide implementation details
+ return preg_replace( '/\s+/', ' ', $wrapper->replaceVars( $sql ) );
+ }
+
+ private function assertResultIs( $expected, $res ) {
+ $this->assertNotNull( $res );
+ $i = 0;
+ foreach ( $res as $row ) {
+ foreach ( $expected[$i] as $key => $value ) {
+ $this->assertTrue( isset( $row->$key ) );
+ $this->assertEquals( $value, $row->$key );
+ }
+ $i++;
+ }
+ $this->assertEquals( count( $expected ), $i, 'Unexpected number of rows' );
+ }
+
+ public static function provideAddQuotes() {
+ return [
+ [ // #0: empty
+ '', "''"
+ ],
+ [ // #1: simple
+ 'foo bar', "'foo bar'"
+ ],
+ [ // #2: including quote
+ 'foo\'bar', "'foo''bar'"
+ ],
+ // #3: including \0 (must be represented as hex, per https://bugs.php.net/bug.php?id=63419)
+ [
+ "x\0y",
+ "x'780079'",
+ ],
+ [ // #4: blob object (must be represented as hex)
+ new Blob( "hello" ),
+ "x'68656c6c6f'",
+ ],
+ [ // #5: null
+ null,
+ "''",
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideAddQuotes()
+ * @covers DatabaseSqlite::addQuotes
+ */
+ public function testAddQuotes( $value, $expected ) {
+ // check quoting
+ $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+ $this->assertEquals( $expected, $db->addQuotes( $value ), 'string not quoted as expected' );
+
+ // ok, quoting works as expected, now try a round trip.
+ $re = $db->query( 'select ' . $db->addQuotes( $value ) );
+
+ $this->assertTrue( $re !== false, 'query failed' );
+
+ $row = $re->fetchRow();
+ if ( $row ) {
+ if ( $value instanceof Blob ) {
+ $value = $value->fetch();
+ }
+
+ $this->assertEquals( $value, $row[0], 'string mangled by the database' );
+ } else {
+ $this->fail( 'query returned no result' );
+ }
+ }
+
+ /**
+ * @covers DatabaseSqlite::replaceVars
+ */
+ public function testReplaceVars() {
+ $this->assertEquals( 'foo', $this->replaceVars( 'foo' ), "Don't break anything accidentally" );
+
+ $this->assertEquals(
+ "CREATE TABLE /**/foo (foo_key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "
+ . "foo_bar TEXT, foo_name TEXT NOT NULL DEFAULT '', foo_int INTEGER, foo_int2 INTEGER );",
+ $this->replaceVars(
+ "CREATE TABLE /**/foo (foo_key int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT, "
+ . "foo_bar char(13), foo_name varchar(255) binary NOT NULL DEFAULT '', "
+ . "foo_int tinyint ( 8 ), foo_int2 int(16) ) ENGINE=MyISAM;"
+ )
+ );
+
+ $this->assertEquals(
+ "CREATE TABLE foo ( foo1 REAL, foo2 REAL, foo3 REAL );",
+ $this->replaceVars(
+ "CREATE TABLE foo ( foo1 FLOAT, foo2 DOUBLE( 1,10), foo3 DOUBLE PRECISION );"
+ )
+ );
+
+ $this->assertEquals( "CREATE TABLE foo ( foo_binary1 BLOB, foo_binary2 BLOB );",
+ $this->replaceVars( "CREATE TABLE foo ( foo_binary1 binary(16), foo_binary2 varbinary(32) );" )
+ );
+
+ $this->assertEquals( "CREATE TABLE text ( text_foo TEXT );",
+ $this->replaceVars( "CREATE TABLE text ( text_foo tinytext );" ),
+ 'Table name changed'
+ );
+
+ $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );",
+ $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY NOT NULL AUTO_INCREMENT );" )
+ );
+ $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );",
+ $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY AUTO_INCREMENT NOT NULL );" )
+ );
+
+ $this->assertEquals( "CREATE TABLE enums( enum1 TEXT, myenum TEXT)",
+ $this->replaceVars( "CREATE TABLE enums( enum1 ENUM('A', 'B'), myenum ENUM ('X', 'Y'))" )
+ );
+
+ $this->assertEquals( "ALTER TABLE foo ADD COLUMN foo_bar INTEGER DEFAULT 42",
+ $this->replaceVars( "ALTER TABLE foo\nADD COLUMN foo_bar int(10) unsigned DEFAULT 42" )
+ );
+
+ $this->assertEquals( "DROP INDEX foo",
+ $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar" )
+ );
+
+ $this->assertEquals( "DROP INDEX foo -- dropping index",
+ $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar -- dropping index" )
+ );
+ $this->assertEquals( "INSERT OR IGNORE INTO foo VALUES ('bar')",
+ $this->replaceVars( "INSERT OR IGNORE INTO foo VALUES ('bar')" )
+ );
+ }
+
+ /**
+ * @covers DatabaseSqlite::tableName
+ */
+ public function testTableName() {
+ // @todo Moar!
+ $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+ $this->assertEquals( 'foo', $db->tableName( 'foo' ) );
+ $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) );
+ $db->tablePrefix( 'foo_' );
+ $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) );
+ $this->assertEquals( 'foo_bar', $db->tableName( 'bar' ) );
+ }
+
+ /**
+ * @covers DatabaseSqlite::duplicateTableStructure
+ */
+ public function testDuplicateTableStructure() {
+ $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+ $db->query( 'CREATE TABLE foo(foo, barfoo)' );
+ $db->query( 'CREATE INDEX index1 ON foo(foo)' );
+ $db->query( 'CREATE UNIQUE INDEX index2 ON foo(barfoo)' );
+
+ $db->duplicateTableStructure( 'foo', 'bar' );
+ $this->assertEquals( 'CREATE TABLE "bar"(foo, barfoo)',
+ $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'bar' ] ),
+ 'Normal table duplication'
+ );
+ $indexList = $db->query( 'PRAGMA INDEX_LIST("bar")' );
+ $index = $indexList->next();
+ $this->assertEquals( 'bar_index1', $index->name );
+ $this->assertEquals( '0', $index->unique );
+ $index = $indexList->next();
+ $this->assertEquals( 'bar_index2', $index->name );
+ $this->assertEquals( '1', $index->unique );
+
+ $db->duplicateTableStructure( 'foo', 'baz', true );
+ $this->assertEquals( 'CREATE TABLE "baz"(foo, barfoo)',
+ $db->selectField( 'sqlite_temp_master', 'sql', [ 'name' => 'baz' ] ),
+ 'Creation of temporary duplicate'
+ );
+ $indexList = $db->query( 'PRAGMA INDEX_LIST("baz")' );
+ $index = $indexList->next();
+ $this->assertEquals( 'baz_index1', $index->name );
+ $this->assertEquals( '0', $index->unique );
+ $index = $indexList->next();
+ $this->assertEquals( 'baz_index2', $index->name );
+ $this->assertEquals( '1', $index->unique );
+ $this->assertEquals( 0,
+ $db->selectField( 'sqlite_master', 'COUNT(*)', [ 'name' => 'baz' ] ),
+ 'Create a temporary duplicate only'
+ );
+ }
+
+ /**
+ * @covers DatabaseSqlite::duplicateTableStructure
+ */
+ public function testDuplicateTableStructureVirtual() {
+ $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+ if ( $db->getFulltextSearchModule() != 'FTS3' ) {
+ $this->markTestSkipped( 'FTS3 not supported, cannot create virtual tables' );
+ }
+ $db->query( 'CREATE VIRTUAL TABLE "foo" USING FTS3(foobar)' );
+
+ $db->duplicateTableStructure( 'foo', 'bar' );
+ $this->assertEquals( 'CREATE VIRTUAL TABLE "bar" USING FTS3(foobar)',
+ $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'bar' ] ),
+ 'Duplication of virtual tables'
+ );
+
+ $db->duplicateTableStructure( 'foo', 'baz', true );
+ $this->assertEquals( 'CREATE VIRTUAL TABLE "baz" USING FTS3(foobar)',
+ $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'baz' ] ),
+ "Can't create temporary virtual tables, should fall back to non-temporary duplication"
+ );
+ }
+
+ /**
+ * @covers DatabaseSqlite::deleteJoin
+ */
+ public function testDeleteJoin() {
+ $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+ $db->query( 'CREATE TABLE a (a_1)', __METHOD__ );
+ $db->query( 'CREATE TABLE b (b_1, b_2)', __METHOD__ );
+ $db->insert( 'a', [
+ [ 'a_1' => 1 ],
+ [ 'a_1' => 2 ],
+ [ 'a_1' => 3 ],
+ ],
+ __METHOD__
+ );
+ $db->insert( 'b', [
+ [ 'b_1' => 2, 'b_2' => 'a' ],
+ [ 'b_1' => 3, 'b_2' => 'b' ],
+ ],
+ __METHOD__
+ );
+ $db->deleteJoin( 'a', 'b', 'a_1', 'b_1', [ 'b_2' => 'a' ], __METHOD__ );
+ $res = $db->query( "SELECT * FROM a", __METHOD__ );
+ $this->assertResultIs( [
+ [ 'a_1' => 1 ],
+ [ 'a_1' => 3 ],
+ ],
+ $res
+ );
+ }
+
+ /**
+ * @coversNothing
+ */
+ public function testEntireSchema() {
+ global $IP;
+
+ $result = Sqlite::checkSqlSyntax( "$IP/maintenance/tables.sql" );
+ if ( $result !== true ) {
+ $this->fail( $result );
+ }
+ $this->assertTrue( true ); // avoid test being marked as incomplete due to lack of assertions
+ }
+
+ /**
+ * Runs upgrades of older databases and compares results with current schema
+ * @todo Currently only checks list of tables
+ * @coversNothing
+ */
+ public function testUpgrades() {
+ global $IP, $wgVersion, $wgProfiler;
+
+ // Versions tested
+ $versions = [
+ // '1.13', disabled for now, was totally screwed up
+ // SQLite wasn't included in 1.14
+ '1.15',
+ '1.16',
+ '1.17',
+ '1.18',
+ '1.19',
+ '1.20',
+ '1.21',
+ '1.22',
+ '1.23',
+ ];
+
+ // Mismatches for these columns we can safely ignore
+ $ignoredColumns = [
+ 'user_newtalk.user_last_timestamp', // r84185
+ ];
+
+ $currentDB = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+ $currentDB->sourceFile( "$IP/maintenance/tables.sql" );
+
+ $profileToDb = false;
+ if ( isset( $wgProfiler['output'] ) ) {
+ $out = $wgProfiler['output'];
+ if ( $out === 'db' ) {
+ $profileToDb = true;
+ } elseif ( is_array( $out ) && in_array( 'db', $out ) ) {
+ $profileToDb = true;
+ }
+ }
+
+ if ( $profileToDb ) {
+ $currentDB->sourceFile( "$IP/maintenance/sqlite/archives/patch-profiling.sql" );
+ }
+ $currentTables = $this->getTables( $currentDB );
+ sort( $currentTables );
+
+ foreach ( $versions as $version ) {
+ $versions = "upgrading from $version to $wgVersion";
+ $db = $this->prepareTestDB( $version );
+ $tables = $this->getTables( $db );
+ $this->assertEquals( $currentTables, $tables, "Different tables $versions" );
+ foreach ( $tables as $table ) {
+ $currentCols = $this->getColumns( $currentDB, $table );
+ $cols = $this->getColumns( $db, $table );
+ $this->assertEquals(
+ array_keys( $currentCols ),
+ array_keys( $cols ),
+ "Mismatching columns for table \"$table\" $versions"
+ );
+ foreach ( $currentCols as $name => $column ) {
+ $fullName = "$table.$name";
+ $this->assertEquals(
+ (bool)$column->pk,
+ (bool)$cols[$name]->pk,
+ "PRIMARY KEY status does not match for column $fullName $versions"
+ );
+ if ( !in_array( $fullName, $ignoredColumns ) ) {
+ $this->assertEquals(
+ (bool)$column->notnull,
+ (bool)$cols[$name]->notnull,
+ "NOT NULL status does not match for column $fullName $versions"
+ );
+ $this->assertEquals(
+ $column->dflt_value,
+ $cols[$name]->dflt_value,
+ "Default values does not match for column $fullName $versions"
+ );
+ }
+ }
+ $currentIndexes = $this->getIndexes( $currentDB, $table );
+ $indexes = $this->getIndexes( $db, $table );
+ $this->assertEquals(
+ array_keys( $currentIndexes ),
+ array_keys( $indexes ),
+ "mismatching indexes for table \"$table\" $versions"
+ );
+ }
+ $db->close();
+ }
+ }
+
+ /**
+ * @covers DatabaseSqlite::insertId
+ */
+ public function testInsertIdType() {
+ $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+
+ $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ );
+ $this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Database creation" );
+
+ $insertion = $db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ );
+ $this->assertTrue( $insertion, "Insertion worked" );
+
+ $this->assertInternalType( 'integer', $db->insertId(), "Actual typecheck" );
+ $this->assertTrue( $db->close(), "closing database" );
+ }
+
+ /**
+ * @covers DatabaseSqlite::insert
+ */
+ public function testInsertAffectedRows() {
+ $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+ $db->query( 'CREATE TABLE testInsertAffectedRows ( foo )', __METHOD__ );
+
+ $insertion = $db->insert(
+ 'testInsertAffectedRows',
+ [
+ [ 'foo' => 10 ],
+ [ 'foo' => 12 ],
+ [ 'foo' => 1555 ],
+ ],
+ __METHOD__
+ );
+ $this->assertTrue( $insertion, "Insertion worked" );
+
+ $this->assertSame( 3, $db->affectedRows() );
+ $this->assertTrue( $db->close(), "closing database" );
+ }
+
+ private function prepareTestDB( $version ) {
+ static $maint = null;
+ if ( $maint === null ) {
+ $maint = new FakeMaintenance();
+ $maint->loadParamsAndArgs( null, [ 'quiet' => 1 ] );
+ }
+
+ global $IP;
+ $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+ $db->sourceFile( "$IP/tests/phpunit/data/db/sqlite/tables-$version.sql" );
+ $updater = DatabaseUpdater::newForDB( $db, false, $maint );
+ $updater->doUpdates( [ 'core' ] );
+
+ return $db;
+ }
+
+ private function getTables( $db ) {
+ $list = array_flip( $db->listTables() );
+ $excluded = [
+ 'external_user', // removed from core in 1.22
+ 'math', // moved out of core in 1.18
+ 'trackbacks', // removed from core in 1.19
+ 'searchindex',
+ 'searchindex_content',
+ 'searchindex_segments',
+ 'searchindex_segdir',
+ // FTS4 ready!!1
+ 'searchindex_docsize',
+ 'searchindex_stat',
+ ];
+ foreach ( $excluded as $t ) {
+ unset( $list[$t] );
+ }
+ $list = array_flip( $list );
+ sort( $list );
+
+ return $list;
+ }
+
+ private function getColumns( $db, $table ) {
+ $cols = [];
+ $res = $db->query( "PRAGMA table_info($table)" );
+ $this->assertNotNull( $res );
+ foreach ( $res as $col ) {
+ $cols[$col->name] = $col;
+ }
+ ksort( $cols );
+
+ return $cols;
+ }
+
+ private function getIndexes( $db, $table ) {
+ $indexes = [];
+ $res = $db->query( "PRAGMA index_list($table)" );
+ $this->assertNotNull( $res );
+ foreach ( $res as $index ) {
+ $res2 = $db->query( "PRAGMA index_info({$index->name})" );
+ $this->assertNotNull( $res2 );
+ $index->columns = [];
+ foreach ( $res2 as $col ) {
+ $index->columns[] = $col;
+ }
+ $indexes[$index->name] = $index;
+ }
+ ksort( $indexes );
+
+ return $indexes;
+ }
+
+ /**
+ * @coversNothing
+ */
+ public function testCaseInsensitiveLike() {
+ // TODO: Test this for all databases
+ $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+ $res = $db->query( 'SELECT "a" LIKE "A" AS a' );
+ $row = $res->fetchRow();
+ $this->assertFalse( (bool)$row['a'] );
+ }
+
+ /**
+ * @covers DatabaseSqlite::numFields
+ */
+ public function testNumFields() {
+ $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+
+ $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ );
+ $this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Failed to create table a" );
+ $res = $db->select( 'a', '*' );
+ $this->assertEquals( 0, $db->numFields( $res ), "expects to get 0 fields for an empty table" );
+ $insertion = $db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ );
+ $this->assertTrue( $insertion, "Insertion failed" );
+ $res = $db->select( 'a', '*' );
+ $this->assertEquals( 1, $db->numFields( $res ), "wrong number of fields" );
+
+ $this->assertTrue( $db->close(), "closing database" );
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\DatabaseSqlite::__toString
+ */
+ public function testToString() {
+ $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+
+ $toString = (string)$db;
+
+ $this->assertContains( 'sqlite object', $toString );
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\DatabaseSqlite::getAttributes()
+ */
+ public function testsAttributes() {
+ $attributes = Database::attributesFromType( 'sqlite' );
+ $this->assertTrue( $attributes[Database::ATTR_DB_LEVEL_LOCKING] );
+ }
+}
<?xml version="1.0" encoding="UTF-8"?>
-<phpunit bootstrap="./bootstrap.php"
+<phpunit bootstrap="./bootstrap.maintenance.php"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.8/phpunit.xsd"
$this->assertSame( 'TEST', $value, 'Copied Data' );
}
+ public function testResetServices() {
+ $services = MediaWikiServices::getInstance();
+
+ // override a service instance
+ $myReadOnlyMode = $this->getMockBuilder( ReadOnlyMode::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->setService( 'ReadOnlyMode', $myReadOnlyMode );
+
+ // sanity check
+ $this->assertSame( $myReadOnlyMode, $services->getService( 'ReadOnlyMode' ) );
+
+ // define a custom service
+ $services->defineService(
+ '_TEST_ResetService_Dummy',
+ function ( MediaWikiServices $services ) {
+ $conf = $services->getMainConfig();
+ return (object)[ 'lang' => $conf->get( 'LanguageCode' ) ];
+ }
+ );
+
+ // sanity check
+ $lang = $services->getMainConfig()->get( 'LanguageCode' );
+ $dummy = $services->getService( '_TEST_ResetService_Dummy' );
+ $this->assertSame( $lang, $dummy->lang );
+
+ // the actual test: change config, reset services.
+ $this->setMwGlobals( 'wgLanguageCode', 'qqx' );
+ $this->resetServices();
+
+ // the overridden service instance should still be there
+ $this->assertSame( $myReadOnlyMode, $services->getService( 'ReadOnlyMode' ) );
+
+ // our custom service should have been re-created with the new language code
+ $dummy2 = $services->getService( '_TEST_ResetService_Dummy' );
+ $this->assertNotSame( $dummy2, $dummy );
+ $this->assertSame( 'qqx', $dummy2->lang );
+ }
+
}
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<phpunit bootstrap="unit/initUnitTests.php"
- xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
- xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.8/phpunit.xsd"
-
- colors="true"
- backupGlobals="false"
- convertErrorsToExceptions="true"
- convertNoticesToExceptions="true"
- convertWarningsToExceptions="true"
- forceCoversAnnotation="true"
- stopOnFailure="false"
- timeoutForSmallTests="10"
- timeoutForMediumTests="30"
- timeoutForLargeTests="60"
- beStrictAboutTestsThatDoNotTestAnything="true"
- beStrictAboutOutputDuringTests="true"
- beStrictAboutTestSize="true"
- verbose="false">
- <testsuites>
- <testsuite name="tests">
- <directory>unit</directory>
- </testsuite>
- </testsuites>
- <groups>
- <exclude>
- <group>Broken</group>
- </exclude>
- </groups>
- <filter>
- <whitelist addUncoveredFilesFromWhitelist="true">
- <directory suffix=".php">../../includes</directory>
- <directory suffix=".php">../../languages</directory>
- <directory suffix=".php">../../maintenance</directory>
- <exclude>
- <directory suffix=".php">../../languages/messages</directory>
- <file>../../languages/data/normalize-ar.php</file>
- <file>../../languages/data/normalize-ml.php</file>
- </exclude>
- </whitelist>
- </filter>
-</phpunit>
+++ /dev/null
-<?php
-/**
- * PHPUnit bootstrap file for the unit test suite.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup Testing
- */
-
-if ( PHP_SAPI !== 'cli' ) {
- die( 'This file is only meant to be executed indirectly by PHPUnit\'s bootstrap process!' );
-}
-
-/**
- * PHPUnit includes the bootstrap file inside a method body, while most MediaWiki startup files
- * assume to be included in the global scope.
- * This utility provides a way to include these files: it makes all globals available in the
- * inclusion scope before including the file, then exports all new or changed globals.
- *
- * @param string $fileName the file to include
- */
-function wfRequireOnceInGlobalScope( $fileName ) {
- // phpcs:disable MediaWiki.Usage.ForbiddenFunctions.extract
- extract( $GLOBALS, EXTR_REFS | EXTR_SKIP );
- // phpcs:enable
-
- require_once $fileName;
-
- foreach ( get_defined_vars() as $varName => $value ) {
- $GLOBALS[$varName] = $value;
- }
-}
-
-define( 'MEDIAWIKI', true );
-define( 'MW_PHPUNIT_TEST', true );
-
-// We don't use a settings file here but some code still assumes that one exists
-define( 'MW_CONFIG_FILE', 'LocalSettings.php' );
-
-$IP = realpath( __DIR__ . '/../../..' );
-
-// these variables must be defined before setup runs
-$GLOBALS['IP'] = $IP;
-$GLOBALS['wgCommandLineMode'] = true;
-
-require_once "$IP/tests/common/TestSetup.php";
-
-wfRequireOnceInGlobalScope( "$IP/includes/AutoLoader.php" );
-wfRequireOnceInGlobalScope( "$IP/includes/Defines.php" );
-wfRequireOnceInGlobalScope( "$IP/includes/DefaultSettings.php" );
-wfRequireOnceInGlobalScope( "$IP/includes/GlobalFunctions.php" );
-
-require_once "$IP/tests/common/TestsAutoLoader.php";
-
-TestSetup::applyInitialConfig();
// On collapse...
$collapsible.on( 'beforeCollapse.mw-collapsible', function () {
- assert.assertTrue( $content.is( ':visible' ), 'first beforeCollapseExpand: content is visible' );
+ assert.assertTrue( $content.css( 'display' ) !== 'none', 'first beforeCollapseExpand: content is visible' );
} );
$collapsible.on( 'afterCollapse.mw-collapsible', function () {
- assert.assertTrue( $content.is( ':hidden' ), 'first afterCollapseExpand: content is hidden' );
+ assert.assertTrue( $content.css( 'display' ) === 'none', 'first afterCollapseExpand: content is hidden' );
// On expand...
$collapsible.on( 'beforeExpand.mw-collapsible', function () {
- assert.assertTrue( $content.is( ':hidden' ), 'second beforeCollapseExpand: content is hidden' );
+ assert.assertTrue( $content.css( 'display' ) === 'none', 'second beforeCollapseExpand: content is hidden' );
} );
$collapsible.on( 'afterExpand.mw-collapsible', function () {
- assert.assertTrue( $content.is( ':visible' ), 'second afterCollapseExpand: content is visible' );
+ assert.assertTrue( $content.css( 'display' ) !== 'none', 'second afterCollapseExpand: content is visible' );
} );
// ...expanding happens here
assert.strictEqual( $content.length, 1, 'content is present' );
assert.strictEqual( $content.find( $toggle ).length, 0, 'toggle is not a descendant of content' );
- assert.assertTrue( $content.is( ':visible' ), 'content is visible' );
+ assert.assertTrue( $content.css( 'display' ) !== 'none', 'content is visible' );
$collapsible.on( 'afterCollapse.mw-collapsible', function () {
- assert.assertTrue( $content.is( ':hidden' ), 'after collapsing: content is hidden' );
+ assert.assertTrue( $content.css( 'display' ) === 'none', 'after collapsing: content is hidden' );
$collapsible.on( 'afterExpand.mw-collapsible', function () {
- assert.assertTrue( $content.is( ':visible' ), 'after expanding: content is visible' );
+ assert.assertTrue( $content.css( 'display' ) !== 'none', 'after expanding: content is visible' );
} );
$toggle.trigger( 'click' );
'<tr><td>' + loremIpsum + '</td><td>' + loremIpsum + '</td></tr>' +
'</table>'
),
- $headerRow = $collapsible.find( 'tr:first' ),
- $contentRow = $collapsible.find( 'tr:last' ),
- $toggle = $headerRow.find( 'td:last .mw-collapsible-toggle' );
+ $headerRow = $collapsible.find( 'tr' ).first(),
+ $contentRow = $collapsible.find( 'tr' ).last(),
+ $toggle = $headerRow.find( 'td' ).last().find( '.mw-collapsible-toggle' );
assert.strictEqual( $toggle.length, 1, 'toggle is added to last cell of first row' );
- assert.assertTrue( $headerRow.is( ':visible' ), 'headerRow is visible' );
- assert.assertTrue( $contentRow.is( ':visible' ), 'contentRow is visible' );
+ assert.assertTrue( $headerRow.css( 'display' ) !== 'none', 'headerRow is visible' );
+ assert.assertTrue( $contentRow.css( 'display' ) !== 'none', 'contentRow is visible' );
$collapsible.on( 'afterCollapse.mw-collapsible', function () {
- assert.assertTrue( $headerRow.is( ':visible' ), 'after collapsing: headerRow is still visible' );
- assert.assertTrue( $contentRow.is( ':hidden' ), 'after collapsing: contentRow is hidden' );
+ assert.assertTrue( $headerRow.css( 'display' ) !== 'none', 'after collapsing: headerRow is still visible' );
+ assert.assertTrue( $contentRow.css( 'display' ) === 'none', 'after collapsing: contentRow is hidden' );
$collapsible.on( 'afterExpand.mw-collapsible', function () {
- assert.assertTrue( $headerRow.is( ':visible' ), 'after expanding: headerRow is still visible' );
- assert.assertTrue( $contentRow.is( ':visible' ), 'after expanding: contentRow is visible' );
+ assert.assertTrue( $headerRow.css( 'display' ) !== 'none', 'after expanding: headerRow is still visible' );
+ assert.assertTrue( $contentRow.css( 'display' ) !== 'none', 'after expanding: contentRow is visible' );
} );
$toggle.trigger( 'click' );
function tableWithCaptionTest( $collapsible, test, assert ) {
var $caption = $collapsible.find( 'caption' ),
- $headerRow = $collapsible.find( 'tr:first' ),
- $contentRow = $collapsible.find( 'tr:last' ),
+ $headerRow = $collapsible.find( 'tr' ).first(),
+ $contentRow = $collapsible.find( 'tr' ).last(),
$toggle = $caption.find( '.mw-collapsible-toggle' );
assert.strictEqual( $toggle.length, 1, 'toggle is added to the end of the caption' );
- assert.assertTrue( $caption.is( ':visible' ), 'caption is visible' );
- assert.assertTrue( $headerRow.is( ':visible' ), 'headerRow is visible' );
- assert.assertTrue( $contentRow.is( ':visible' ), 'contentRow is visible' );
+ assert.assertTrue( $caption.css( 'display' ) !== 'none', 'caption is visible' );
+ assert.assertTrue( $headerRow.css( 'display' ) !== 'none', 'headerRow is visible' );
+ assert.assertTrue( $contentRow.css( 'display' ) !== 'none', 'contentRow is visible' );
$collapsible.on( 'afterCollapse.mw-collapsible', function () {
- assert.assertTrue( $caption.is( ':visible' ), 'after collapsing: caption is still visible' );
- assert.assertTrue( $headerRow.is( ':hidden' ), 'after collapsing: headerRow is hidden' );
- assert.assertTrue( $contentRow.is( ':hidden' ), 'after collapsing: contentRow is hidden' );
+ assert.assertTrue( $caption.css( 'display' ) !== 'none', 'after collapsing: caption is still visible' );
+ assert.assertTrue( $headerRow.css( 'display' ) === 'none', 'after collapsing: headerRow is hidden' );
+ assert.assertTrue( $contentRow.css( 'display' ) === 'none', 'after collapsing: contentRow is hidden' );
$collapsible.on( 'afterExpand.mw-collapsible', function () {
- assert.assertTrue( $caption.is( ':visible' ), 'after expanding: caption is still visible' );
- assert.assertTrue( $headerRow.is( ':visible' ), 'after expanding: headerRow is visible' );
- assert.assertTrue( $contentRow.is( ':visible' ), 'after expanding: contentRow is visible' );
+ assert.assertTrue( $caption.css( 'display' ) !== 'none', 'after expanding: caption is still visible' );
+ assert.assertTrue( $headerRow.css( 'display' ) !== 'none', 'after expanding: headerRow is visible' );
+ assert.assertTrue( $contentRow.css( 'display' ) !== 'none', 'after expanding: contentRow is visible' );
} );
$toggle.trigger( 'click' );
'</' + listType + '>'
),
$toggleItem = $collapsible.find( 'li.mw-collapsible-toggle-li:first-child' ),
- $contentItem = $collapsible.find( 'li:last' ),
+ $contentItem = $collapsible.find( 'li' ).last(),
$toggle = $toggleItem.find( '.mw-collapsible-toggle' );
assert.strictEqual( $toggle.length, 1, 'toggle is present, added inside new zeroth list item' );
- assert.assertTrue( $toggleItem.is( ':visible' ), 'toggleItem is visible' );
- assert.assertTrue( $contentItem.is( ':visible' ), 'contentItem is visible' );
+ assert.assertTrue( $toggleItem.css( 'display' ) !== 'none', 'toggleItem is visible' );
+ assert.assertTrue( $contentItem.css( 'display' ) !== 'none', 'contentItem is visible' );
$collapsible.on( 'afterCollapse.mw-collapsible', function () {
- assert.assertTrue( $toggleItem.is( ':visible' ), 'after collapsing: toggleItem is still visible' );
- assert.assertTrue( $contentItem.is( ':hidden' ), 'after collapsing: contentItem is hidden' );
+ assert.assertTrue( $toggleItem.css( 'display' ) !== 'none', 'after collapsing: toggleItem is still visible' );
+ assert.assertTrue( $contentItem.css( 'display' ) === 'none', 'after collapsing: contentItem is hidden' );
$collapsible.on( 'afterExpand.mw-collapsible', function () {
- assert.assertTrue( $toggleItem.is( ':visible' ), 'after expanding: toggleItem is still visible' );
- assert.assertTrue( $contentItem.is( ':visible' ), 'after expanding: contentItem is visible' );
+ assert.assertTrue( $toggleItem.css( 'display' ) !== 'none', 'after expanding: toggleItem is still visible' );
+ assert.assertTrue( $contentItem.css( 'display' ) !== 'none', 'after expanding: contentItem is visible' );
} );
$toggle.trigger( 'click' );
),
$content = $collapsible.find( '.mw-collapsible-content' );
- assert.assertTrue( $content.is( ':visible' ), 'content is visible' );
+ assert.assertTrue( $content.css( 'display' ) !== 'none', 'content is visible' );
$collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' );
- assert.assertTrue( $content.is( ':hidden' ), 'after collapsing: content is hidden' );
+ assert.assertTrue( $content.css( 'display' ) === 'none', 'after collapsing: content is hidden' );
} );
QUnit.test( 'mw-made-collapsible data added', function ( assert ) {
$content = $collapsible.find( '.mw-collapsible-content' );
// Synchronous - mw-collapsed should cause instantHide: true to be used on initial collapsing
- assert.assertTrue( $content.is( ':hidden' ), 'content is hidden' );
+ assert.assertTrue( $content.css( 'display' ) === 'none', 'content is hidden' );
$collapsible.on( 'afterExpand.mw-collapsible', function () {
- assert.assertTrue( $content.is( ':visible' ), 'after expanding: content is visible' );
+ assert.assertTrue( $content.css( 'display' ) !== 'none', 'after expanding: content is visible' );
} );
$collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' );
$content = $collapsible.find( '.mw-collapsible-content' );
// Synchronous - collapsed: true should cause instantHide: true to be used on initial collapsing
- assert.assertTrue( $content.is( ':hidden' ), 'content is hidden' );
+ assert.assertTrue( $content.css( 'display' ) === 'none', 'content is hidden' );
$collapsible.on( 'afterExpand.mw-collapsible', function () {
- assert.assertTrue( $content.is( ':visible' ), 'after expanding: content is visible' );
+ assert.assertTrue( $content.css( 'display' ) !== 'none', 'after expanding: content is visible' );
} );
$collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' );
$content = $collapsible.find( '.mw-collapsible-content' );
$collapsible.find( '.mw-collapsible-toggle a' ).trigger( 'click' );
- assert.assertTrue( $content.is( ':visible' ), 'click event on link inside toggle passes through (content not toggled)' );
+ assert.assertTrue( $content.css( 'display' ) !== 'none', 'click event on link inside toggle passes through (content not toggled)' );
$collapsible.find( '.mw-collapsible-toggle b' ).trigger( 'click' );
- assert.assertTrue( $content.is( ':hidden' ), 'click event on non-link inside toggle toggles content' );
+ assert.assertTrue( $content.css( 'display' ) === 'none', 'click event on non-link inside toggle toggles content' );
} );
QUnit.test( 'click on non-link inside toggler counts as trigger', function ( assert ) {
$content = $collapsible.find( '.mw-collapsible-content' );
$collapsible.find( '.mw-collapsible-toggle a' ).trigger( 'click' );
- assert.assertTrue( $content.is( ':hidden' ), 'click event on link (with no href) inside toggle toggles content' );
+ assert.assertTrue( $content.css( 'display' ) === 'none', 'click event on link (with no href) inside toggle toggles content' );
} );
QUnit.test( 'collapse/expand text (data-collapsetext, data-expandtext)', function ( assert ) {
.appendTo( '#qunit-fixture' ).makeCollapsible(),
$content = $clone.find( '.mw-collapsible-content' );
- assert.assertTrue( $content.is( ':visible' ), 'content is visible' );
+ assert.assertTrue( $content.css( 'display' ) !== 'none', 'content is visible' );
$clone.on( 'afterCollapse.mw-collapsible', function () {
- assert.assertTrue( $content.is( ':hidden' ), 'after collapsing: content is hidden' );
+ assert.assertTrue( $content.css( 'display' ) === 'none', 'after collapsing: content is hidden' );
} );
$clone.find( '.mw-collapsible-toggle a' ).trigger( 'click' );
planetsAscName,
function ( $table ) {
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
tableTest(
planetsAscName,
function ( $table ) {
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
tableTest(
planetsAscName,
function ( $table ) {
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
- $table.find( '.headerSort:eq(1)' ).trigger( 'click' );
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
tableTest(
reversed( planetsAscName ),
function ( $table ) {
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ).trigger( 'click' );
}
);
tableTest(
planetsAscRadius,
function ( $table ) {
$table.tablesorter();
- $table.find( '.headerSort:eq(1)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' );
}
);
tableTest(
reversed( planetsAscRadius ),
function ( $table ) {
$table.tablesorter();
- $table.find( '.headerSort:eq(1)' ).trigger( 'click' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' ).trigger( 'click' );
}
);
tableTest(
$table.tablesorter(
{ sortList: [ { 0: 'asc' }, { 1: 'asc' } ] }
);
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
tableTest(
$table.tablesorter(
{ sortList: [ { 0: 'desc' }, { 1: 'desc' } ] }
);
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
// Pretend to click while pressing the multi-sort key
event = $.Event( 'click' );
event[ $table.data( 'tablesorter' ).config.sortMultiSortKey ] = true;
- $table.find( '.headerSort:eq(1)' ).trigger( event );
+ $table.find( '.headerSort' ).eq( 1 ).trigger( event );
}
);
QUnit.test( 'Reset sorting making table appear unsorted', function ( assert ) {
[ aaa1, aab5, abc3, bbc2, caa4 ],
function ( $table ) {
// Make colspanned header for test
- $table.find( 'tr:eq(0) th:eq(1), tr:eq(0) th:eq(2)' ).remove();
- $table.find( 'tr:eq(0) th:eq(0)' ).attr( 'colspan', '3' );
+ $table.find( 'tr th' ).eq( 1 ).remove();
+ $table.find( 'tr th' ).eq( 1 ).remove();
+ $table.find( 'tr th' ).eq( 0 ).attr( 'colspan', '3' );
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
tableTest( 'Sorting with colspanned headers: sort spanned column twice',
[ caa4, bbc2, abc3, aab5, aaa1 ],
function ( $table ) {
// Make colspanned header for test
- $table.find( 'tr:eq(0) th:eq(1), tr:eq(0) th:eq(2)' ).remove();
- $table.find( 'tr:eq(0) th:eq(0)' ).attr( 'colspan', '3' );
+ $table.find( 'tr th' ).eq( 1 ).remove();
+ $table.find( 'tr th' ).eq( 1 ).remove();
+ $table.find( 'tr' ).eq( 0 ).find( 'th' ).eq( 0 ).attr( 'colspan', '3' );
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
tableTest( 'Sorting with colspanned headers: subsequent column',
[ aaa1, bbc2, abc3, caa4, aab5 ],
function ( $table ) {
// Make colspanned header for test
- $table.find( 'tr:eq(0) th:eq(1), tr:eq(0) th:eq(2)' ).remove();
- $table.find( 'tr:eq(0) th:eq(0)' ).attr( 'colspan', '3' );
+ $table.find( 'tr th' ).eq( 1 ).remove();
+ $table.find( 'tr th' ).eq( 1 ).remove();
+ $table.find( 'tr th' ).eq( 0 ).attr( 'colspan', '3' );
$table.tablesorter();
- $table.find( '.headerSort:eq(1)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' );
}
);
tableTest( 'Sorting with colspanned headers: sort subsequent column twice',
[ aab5, caa4, abc3, bbc2, aaa1 ],
function ( $table ) {
// Make colspanned header for test
- $table.find( 'tr:eq(0) th:eq(1), tr:eq(0) th:eq(2)' ).remove();
- $table.find( 'tr:eq(0) th:eq(0)' ).attr( 'colspan', '3' );
+ $table.find( 'tr th' ).eq( 1 ).remove();
+ $table.find( 'tr th' ).eq( 1 ).remove();
+ $table.find( 'tr th' ).eq( 0 ).attr( 'colspan', '3' );
$table.tablesorter();
- $table.find( '.headerSort:eq(1)' ).trigger( 'click' );
- $table.find( '.headerSort:eq(1)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' );
}
);
QUnit.test( 'Basic planet table: one unsortable column', function ( assert ) {
var $table = tableCreate( header, planets ),
$cell;
- $table.find( 'tr:eq(0) > th:eq(0)' ).addClass( 'unsortable' );
+ $table.find( 'tr > th' ).eq( 0 ).addClass( 'unsortable' );
$table.tablesorter();
- $table.find( 'tr:eq(0) > th:eq(0)' ).trigger( 'click' );
+ $table.find( 'tr > th' ).eq( 0 ).trigger( 'click' );
assert.deepEqual(
tableExtract( $table ),
'table not sorted'
);
- $cell = $table.find( 'tr:eq(0) > th:eq(0)' );
- $table.find( 'tr:eq(0) > th:eq(1)' ).trigger( 'click' );
+ $cell = $table.find( 'tr > th' ).eq( 0 );
+ $table.find( 'tr > th' ).eq( 1 ).trigger( 'click' );
assert.strictEqual(
$cell.hasClass( 'headerSortUp' ) || $cell.hasClass( 'headerSortDown' ),
mw.config.set( 'wgPageContentLanguage', 'de' );
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
mw.config.set( 'wgDefaultDateFormat', 'mdy' );
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
ipv4Sorted,
function ( $table ) {
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
tableTest(
reversed( ipv4Sorted ),
function ( $table ) {
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ).trigger( 'click' );
}
);
} );
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
} );
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
// Modify the table to have a multiple-row-spanning cell:
// - Remove 2nd cell of 4th row, and, 2nd cell or 5th row.
- $table.find( 'tr:eq(3) td:eq(1), tr:eq(4) td:eq(1)' ).remove();
+ $table.find( 'tr' ).eq( 3 ).find( 'td' ).eq( 1 ).remove();
+ $table.find( 'tr' ).eq( 4 ).find( 'td' ).eq( 1 ).remove();
// - Set rowspan for 2nd cell of 3rd row to 3.
// This covers the removed cell in the 4th and 5th row.
- $table.find( 'tr:eq(2) td:eq(1)' ).attr( 'rowspan', '3' );
+ $table.find( 'tr' ).eq( 2 ).find( 'td' ).eq( 1 ).attr( 'rowspan', '3' );
$table.tablesorter();
assert.strictEqual(
- $table.find( 'tr:eq(2) td:eq(1)' ).prop( 'rowSpan' ),
+ $table.find( 'tr' ).eq( 2 ).find( 'td' ).eq( 1 ).prop( 'rowSpan' ),
3,
'Rowspan not exploded'
);
function ( $table ) {
// Modify the table to have a multiple-row-spanning cell:
// - Remove 2nd cell of 4th row, and, 2nd cell or 5th row.
- $table.find( 'tr:eq(3) td:eq(1), tr:eq(4) td:eq(1)' ).remove();
+ $table.find( 'tr' ).eq( 3 ).find( 'td' ).eq( 1 ).remove();
+ $table.find( 'tr' ).eq( 4 ).find( 'td' ).eq( 1 ).remove();
// - Set rowspan for 2nd cell of 3rd row to 3.
// This covers the removed cell in the 4th and 5th row.
- $table.find( 'tr:eq(2) td:eq(1)' ).attr( 'rowspan', '3' );
+ $table.find( 'tr' ).eq( 2 ).find( 'td' ).eq( 1 ).attr( 'rowspan', '3' );
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
tableTest(
function ( $table ) {
// Modify the table to have a multiple-row-spanning cell:
// - Remove 2nd cell of 4th row, and, 2nd cell or 5th row.
- $table.find( 'tr:eq(3) td:eq(1), tr:eq(4) td:eq(1)' ).remove();
+ $table.find( 'tr' ).eq( 3 ).find( 'td' ).eq( 1 ).remove();
+ $table.find( 'tr' ).eq( 4 ).find( 'td' ).eq( 1 ).remove();
// - Set rowspan for 2nd cell of 3rd row to 3.
// This covers the removed cell in the 4th and 5th row.
- $table.find( 'tr:eq(2) td:eq(1)' ).attr( 'rowspan', '3' );
+ $table.find( 'tr' ).eq( 2 ).find( 'td' ).eq( 1 ).attr( 'rowspan', '3' );
$table.tablesorter( { sortList: [
{ 0: 'asc' }
function ( $table ) {
// Modify the table to have a multiple-row-spanning cell:
// - Remove 1st cell of 4th row, and, 1st cell or 5th row.
- $table.find( 'tr:eq(3) td:eq(0), tr:eq(4) td:eq(0)' ).remove();
+ $table.find( 'tr' ).eq( 3 ).find( 'td' ).eq( 0 ).remove();
+ $table.find( 'tr' ).eq( 4 ).find( 'td' ).eq( 0 ).remove();
// - Set rowspan for 1st cell of 3rd row to 3.
// This covers the removed cell in the 4th and 5th row.
- $table.find( 'tr:eq(2) td:eq(0)' ).attr( 'rowspan', '3' );
+ $table.find( 'tr' ).eq( 2 ).find( 'td' ).eq( 0 ).attr( 'rowspan', '3' );
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
mw.config.set( 'wgDefaultDateFormat', 'mdy' );
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
currencySorted,
function ( $table ) {
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
planets,
planetsAscNameLegacy,
function ( $table ) {
- $table.find( 'tr:last' ).addClass( 'sortbottom' );
+ $table.find( 'tr' ).last().addClass( 'sortbottom' );
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
'</table>'
);
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
assert.strictEqual(
$table.data( 'tablesorter' ).config.parsers[ 0 ].id,
'<tr><td data-sort-value="Cherry">Dolphin</td></tr>' +
'</tbody></table>'
);
- $table.tablesorter().find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' );
data = [];
$table.find( 'tbody > tr' ).each( function ( i, tr ) {
'<tr><td><span data-sort-value="D">H</span></td></tr>' +
'</tbody></table>'
);
- $table.tablesorter().find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' );
data = [];
$table.find( 'tbody > tr' ).each( function ( i, tr ) {
'</tbody></table>'
);
// initialize table sorter and sort once
- $table
- .tablesorter()
- .find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' );
// Change the sortValue data properties (T40152)
// - change data
$table.find( 'td:contains(G)' ).removeData( 'sortValue' );
// Now sort again (twice, so it is back at Ascending)
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
data = [];
$table.find( 'tbody > tr' ).each( function ( i, tr ) {
[ 'Numbers' ], numbers, numbersAsc,
function ( $table ) {
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
[ 'Numbers' ], numbers, reversed( numbersAsc ),
function ( $table ) {
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ).trigger( 'click' );
}
);
// TODO add numbers sorting tests for T10115 with a different language
$table.tablesorter();
assert.strictEqual(
- $table.find( '> thead:eq(0) > tr > th.headerSort' ).length,
+ $table.find( '> thead > tr > th.headerSort' ).length,
1,
'Child tables inside a headercell should not interfere with sortable headers (T34888)'
);
mw.config.set( 'wgDefaultDateFormat', 'mdy' );
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
mw.config.set( 'wgDefaultDateFormat', 'dmy' );
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
mw.config.set( 'wgDefaultDateFormat', 'dmy' );
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
}
);
'<tr><td>1</td></tr>' +
'</table>'
);
- $table.tablesorter().find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' );
assert.strictEqual(
$table.find( 'td' ).first().text(),
'<tr><td><img alt="A" />C</tr>' +
'</table>'
);
- $table.tablesorter().find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' );
assert.strictEqual(
$table.find( 'td' ).text(),
'<tr><td>4</td></tr>' +
'</table>'
);
- $table.tablesorter().find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' );
assert.strictEqual(
$table.find( 'td' ).text(),
'</tbody></table>' );
$table.tablesorter();
- assert.strictEqual( $table.find( 'tr:eq(1) th:eq(1)' ).data( 'headerIndex' ),
+ assert.strictEqual( $table.find( 'tr' ).eq( 1 ).find( 'th' ).eq( 1 ).data( 'headerIndex' ),
2,
'Incorrect index of sort header'
);
'</table>'
);
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
// now the first row have 2 columns
- $table.find( '.headerSort:eq(1)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' );
parsers = $table.data( 'tablesorter' ).config.parsers;
);
assert.strictEqual(
- parsers[ 1 ].format( $table.find( 'tbody > tr > td:eq(1)' ).text() ),
+ parsers[ 1 ].format( $table.find( 'tbody > tr > td' ).eq( 1 ).text() ),
-Infinity,
'empty cell is sorted as number -Infinity'
);
'</table>'
);
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
assert.deepEqual(
tableExtract( $table ),
'</table>'
);
$table.tablesorter();
- $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+ $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
assert.deepEqual(
tableExtract( $table ),
$( '#qunit-fixture' ).append( $toc );
mw.hook( 'wikipage.content' ).fire( $( '#qunit-fixture' ) );
- $tocList = $toc.find( 'ul:first' );
+ $tocList = $toc.find( 'ul' ).first();
$toggleLink = $toc.find( '.togglelink' );
assert.strictEqual( $toggleLink.length, 1, 'Toggle link is added to the table of contents' );
- assert.strictEqual( $tocList.is( ':hidden' ), false, 'The table of contents is now visible' );
+ assert.strictEqual( $toc.hasClass( 'tochidden' ), false, 'The table of contents is now visible' );
$toggleLink.trigger( 'click' );
return $tocList.promise().then( function () {
- assert.strictEqual( $tocList.is( ':hidden' ), true, 'The table of contents is now hidden' );
-
+ assert.strictEqual( $toc.hasClass( 'tochidden' ), true, 'The table of contents is now hidden' );
$toggleLink.trigger( 'click' );
return $tocList.promise();
} );
'Default modules', 't-rldm-nonexistent', 'List of all default modules ', 'd', '#t-rl-nonexistent' );
assert.strictEqual(
tbRLDMnonexistentid,
- $( '#p-test-tb li:last' )[ 0 ],
+ $( '#p-test-tb li' ).last()[ 0 ],
'Next node as non-matching CSS selector falls back to appending'
);
'Default modules', 't-rldm-empty-jquery', 'List of all default modules ', 'd', $( '#t-rl-nonexistent' ) );
assert.strictEqual(
tbRLDMemptyjquery,
- $( '#p-test-tb li:last' )[ 0 ],
+ $( '#p-test-tb li' ).last()[ 0 ],
'Next node as empty jQuery object falls back to appending'
);
} );